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; 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 = { 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); configService = module.get(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); }); }); });