xpeditis2.0/apps/backend/test/integration/maersk.connector.spec.ts
David-Henri ARNAUD 1044900e98 feature phase
2025-10-08 16:56:27 +02:00

418 lines
13 KiB
TypeScript

import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { MaerskConnector } from '../../src/infrastructure/carriers/maersk/maersk.connector';
import { CarrierRateSearchInput } from '../../src/domain/ports/out/carrier-connector.port';
import { CarrierTimeoutException } from '../../src/domain/exceptions/carrier-timeout.exception';
import { CarrierUnavailableException } from '../../src/domain/exceptions/carrier-unavailable.exception';
// Simple UUID generator for tests
const generateUuid = () => 'test-uuid-' + Math.random().toString(36).substring(2, 15);
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('MaerskConnector (Integration)', () => {
let connector: MaerskConnector;
let configService: ConfigService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MaerskConnector,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => {
const config: Record<string, any> = {
MAERSK_API_BASE_URL: 'https://api.maersk.com',
MAERSK_API_KEY: 'test-api-key-12345',
MAERSK_TIMEOUT: 5000,
};
return config[key];
}),
},
},
],
}).compile();
connector = module.get<MaerskConnector>(MaerskConnector);
configService = module.get<ConfigService>(ConfigService);
// Mock axios.create to return a mocked instance
mockedAxios.create = jest.fn().mockReturnValue({
request: jest.fn(),
interceptors: {
request: { use: jest.fn() },
response: { use: jest.fn() },
},
} as any);
});
afterEach(() => {
jest.clearAllMocks();
});
function createTestSearchInput(): CarrierRateSearchInput {
return {
origin: 'NLRTM',
destination: 'CNSHA',
departureDate: new Date('2025-02-01'),
containerType: '40HC',
mode: 'FCL',
quantity: 2,
weight: 20000,
isHazmat: false,
};
}
function createMaerskApiSuccessResponse() {
return {
status: 200,
statusText: 'OK',
data: {
results: [
{
id: generateUuid(),
pricing: {
currency: 'USD',
oceanFreight: 1500.0,
charges: [
{
name: 'BAF',
description: 'Bunker Adjustment Factor',
amount: 150.0,
},
{
name: 'CAF',
description: 'Currency Adjustment Factor',
amount: 50.0,
},
],
totalAmount: 1700.0,
},
routeDetails: {
origin: {
unlocCode: 'NLRTM',
cityName: 'Rotterdam',
countryName: 'Netherlands',
},
destination: {
unlocCode: 'CNSHA',
cityName: 'Shanghai',
countryName: 'China',
},
departureDate: '2025-02-01T10:00:00Z',
arrivalDate: '2025-03-03T14:00:00Z',
transitTime: 30,
},
serviceDetails: {
serviceName: 'AE1/Shoex',
vesselName: 'MAERSK ESSEX',
vesselImo: '9632179',
},
availability: {
available: true,
totalSlots: 100,
availableSlots: 85,
},
},
{
id: generateUuid(),
pricing: {
currency: 'USD',
oceanFreight: 1650.0,
charges: [
{
name: 'BAF',
description: 'Bunker Adjustment Factor',
amount: 165.0,
},
],
totalAmount: 1815.0,
},
routeDetails: {
origin: {
unlocCode: 'NLRTM',
cityName: 'Rotterdam',
countryName: 'Netherlands',
},
destination: {
unlocCode: 'CNSHA',
cityName: 'Shanghai',
countryName: 'China',
},
departureDate: '2025-02-08T12:00:00Z',
arrivalDate: '2025-03-08T16:00:00Z',
transitTime: 28,
},
serviceDetails: {
serviceName: 'AE7/Condor',
vesselName: 'MAERSK SENTOSA',
vesselImo: '9778844',
},
availability: {
available: true,
totalSlots: 120,
availableSlots: 95,
},
},
],
},
};
}
describe('searchRates', () => {
it('should successfully search rates and return mapped quotes', async () => {
const input = createTestSearchInput();
const mockResponse = createMaerskApiSuccessResponse();
// Mock the HTTP client request method
const mockHttpClient = (connector as any).httpClient;
mockHttpClient.request = jest.fn().mockResolvedValue(mockResponse);
const quotes = await connector.searchRates(input);
expect(quotes).toBeDefined();
expect(quotes.length).toBe(2);
// Verify first quote
const quote1 = quotes[0];
expect(quote1.carrierName).toBe('Maersk Line');
expect(quote1.carrierCode).toBe('MAERSK');
expect(quote1.origin.code).toBe('NLRTM');
expect(quote1.destination.code).toBe('CNSHA');
expect(quote1.pricing.baseFreight).toBe(1500.0);
expect(quote1.pricing.totalAmount).toBe(1700.0);
expect(quote1.pricing.currency).toBe('USD');
expect(quote1.pricing.surcharges).toHaveLength(2);
expect(quote1.transitDays).toBe(30);
// Verify second quote
const quote2 = quotes[1];
expect(quote2.pricing.baseFreight).toBe(1650.0);
expect(quote2.pricing.totalAmount).toBe(1815.0);
expect(quote2.transitDays).toBe(28);
});
it('should map surcharges correctly', async () => {
const input = createTestSearchInput();
const mockResponse = createMaerskApiSuccessResponse();
const mockHttpClient = (connector as any).httpClient;
mockHttpClient.request = jest.fn().mockResolvedValue(mockResponse);
const quotes = await connector.searchRates(input);
const surcharges = quotes[0].pricing.surcharges;
expect(surcharges).toHaveLength(2);
expect(surcharges[0]).toEqual({
code: 'BAF',
name: 'Bunker Adjustment Factor',
amount: 150.0,
});
expect(surcharges[1]).toEqual({
code: 'CAF',
name: 'Currency Adjustment Factor',
amount: 50.0,
});
});
it('should include vessel information in route segments', async () => {
const input = createTestSearchInput();
const mockResponse = createMaerskApiSuccessResponse();
const mockHttpClient = (connector as any).httpClient;
mockHttpClient.request = jest.fn().mockResolvedValue(mockResponse);
const quotes = await connector.searchRates(input);
expect(quotes[0].route).toBeDefined();
expect(Array.isArray(quotes[0].route)).toBe(true);
// Vessel name should be in route segments
const hasVesselInfo = quotes[0].route.some((seg) => seg.vesselName);
expect(hasVesselInfo).toBe(true);
});
it('should handle empty results gracefully', async () => {
const input = createTestSearchInput();
const mockResponse = {
status: 200,
data: { results: [] },
};
const mockHttpClient = (connector as any).httpClient;
mockHttpClient.request = jest.fn().mockResolvedValue(mockResponse);
const quotes = await connector.searchRates(input);
expect(quotes).toEqual([]);
});
it('should return empty array on API error', async () => {
const input = createTestSearchInput();
const mockHttpClient = (connector as any).httpClient;
mockHttpClient.request = jest.fn().mockRejectedValue(new Error('API Error'));
const quotes = await connector.searchRates(input);
expect(quotes).toEqual([]);
});
it('should handle timeout errors', async () => {
const input = createTestSearchInput();
const mockHttpClient = (connector as any).httpClient;
const timeoutError = new Error('Timeout');
(timeoutError as any).code = 'ECONNABORTED';
mockHttpClient.request = jest.fn().mockRejectedValue(timeoutError);
const quotes = await connector.searchRates(input);
// Should return empty array instead of throwing
expect(quotes).toEqual([]);
});
});
describe('healthCheck', () => {
it('should return true when API is reachable', async () => {
const mockHttpClient = (connector as any).httpClient;
mockHttpClient.request = jest.fn().mockResolvedValue({
status: 200,
data: { status: 'ok' },
});
const health = await connector.healthCheck();
expect(health).toBe(true);
});
it('should return false when API is unreachable', async () => {
const mockHttpClient = (connector as any).httpClient;
mockHttpClient.request = jest.fn().mockRejectedValue(new Error('Connection failed'));
const health = await connector.healthCheck();
expect(health).toBe(false);
});
});
describe('circuit breaker', () => {
it('should open circuit breaker after consecutive failures', async () => {
const input = createTestSearchInput();
const mockHttpClient = (connector as any).httpClient;
// Simulate multiple failures
mockHttpClient.request = jest.fn().mockRejectedValue(new Error('Service unavailable'));
// Make multiple requests to trigger circuit breaker
for (let i = 0; i < 5; i++) {
await connector.searchRates(input);
}
// Circuit breaker should now be open
const circuitBreaker = (connector as any).circuitBreaker;
expect(circuitBreaker.opened).toBe(true);
});
});
describe('request mapping', () => {
it('should send correctly formatted request to Maersk API', async () => {
const input = createTestSearchInput();
const mockResponse = createMaerskApiSuccessResponse();
const mockHttpClient = (connector as any).httpClient;
const requestSpy = jest.fn().mockResolvedValue(mockResponse);
mockHttpClient.request = requestSpy;
await connector.searchRates(input);
expect(requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
url: '/rates/search',
headers: expect.objectContaining({
'API-Key': 'test-api-key-12345',
}),
data: expect.objectContaining({
origin: 'NLRTM',
destination: 'CNSHA',
containerType: '40HC',
quantity: 2,
}),
})
);
});
it('should include departure date in request', async () => {
const input = createTestSearchInput();
const mockResponse = createMaerskApiSuccessResponse();
const mockHttpClient = (connector as any).httpClient;
const requestSpy = jest.fn().mockResolvedValue(mockResponse);
mockHttpClient.request = requestSpy;
await connector.searchRates(input);
const requestData = requestSpy.mock.calls[0][0].data;
expect(requestData.departureDate).toBeDefined();
expect(new Date(requestData.departureDate)).toEqual(input.departureDate);
});
});
describe('error scenarios', () => {
it('should handle 401 unauthorized gracefully', async () => {
const input = createTestSearchInput();
const mockHttpClient = (connector as any).httpClient;
const error: any = new Error('Unauthorized');
error.response = { status: 401 };
mockHttpClient.request = jest.fn().mockRejectedValue(error);
const quotes = await connector.searchRates(input);
expect(quotes).toEqual([]);
});
it('should handle 429 rate limit gracefully', async () => {
const input = createTestSearchInput();
const mockHttpClient = (connector as any).httpClient;
const error: any = new Error('Too Many Requests');
error.response = { status: 429 };
mockHttpClient.request = jest.fn().mockRejectedValue(error);
const quotes = await connector.searchRates(input);
expect(quotes).toEqual([]);
});
it('should handle 500 server error gracefully', async () => {
const input = createTestSearchInput();
const mockHttpClient = (connector as any).httpClient;
const error: any = new Error('Internal Server Error');
error.response = { status: 500 };
mockHttpClient.request = jest.fn().mockRejectedValue(error);
const quotes = await connector.searchRates(input);
expect(quotes).toEqual([]);
});
it('should handle malformed response data', async () => {
const input = createTestSearchInput();
const mockHttpClient = (connector as any).httpClient;
// Response missing required fields
mockHttpClient.request = jest.fn().mockResolvedValue({
status: 200,
data: { results: [{ invalidStructure: true }] },
});
const quotes = await connector.searchRates(input);
// Should handle gracefully, possibly returning empty array or partial results
expect(Array.isArray(quotes)).toBe(true);
});
});
});