418 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|