Add fetchPage() with axios, timeout, and retry logic
All checks were successful
CI/CD Pipeline - Apartment API / Scan Dependencies (pull_request) Successful in 12s
CI/CD Pipeline - Apartment API / Run Linting (pull_request) Successful in 9m36s
CI/CD Pipeline - Apartment API / Run Tests (pull_request) Successful in 9m42s
CI/CD Pipeline - Apartment API / Send Webhook Notification (pull_request) Successful in 2s
CI/CD Pipeline - Apartment API / Build & Push Image (pull_request) Has been skipped
CI/CD Pipeline - Apartment API / Deploy to Production (pull_request) Has been skipped
All checks were successful
CI/CD Pipeline - Apartment API / Scan Dependencies (pull_request) Successful in 12s
CI/CD Pipeline - Apartment API / Run Linting (pull_request) Successful in 9m36s
CI/CD Pipeline - Apartment API / Run Tests (pull_request) Successful in 9m42s
CI/CD Pipeline - Apartment API / Send Webhook Notification (pull_request) Successful in 2s
CI/CD Pipeline - Apartment API / Build & Push Image (pull_request) Has been skipped
CI/CD Pipeline - Apartment API / Deploy to Production (pull_request) Has been skipped
Implement fetchPage() function for HTTP scraping: - Uses axios for HTTP GET requests - Configurable timeout (default 30s) and User-Agent header - Retry with exponential backoff (1s, 2s, 4s) on 5xx and network errors - Does not retry on 4xx client errors - Logs each attempt with attempt number and error details - Returns HTML string on success, throws after retries exhausted
This commit is contained in:
535
__tests__/scraper/fetchPage.test.js
Normal file
535
__tests__/scraper/fetchPage.test.js
Normal file
@ -0,0 +1,535 @@
|
||||
/**
|
||||
* Tests for fetchPage function
|
||||
*
|
||||
* Acceptance Criteria:
|
||||
* - Uses axios for HTTP GET
|
||||
* - Enforces timeout from config (default 30s)
|
||||
* - Sets User-Agent header from config
|
||||
* - Implements retry with exponential backoff (1s, 2s, 4s)
|
||||
* - Retries on 5xx errors and network timeouts
|
||||
* - Does NOT retry on 4xx errors
|
||||
* - Logs each attempt with attempt number
|
||||
* - Returns HTML string on success
|
||||
* - Throws error after all retries exhausted
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
// Mock axios
|
||||
jest.mock('axios');
|
||||
|
||||
// Mock config
|
||||
jest.mock('../../config/scraper', () => ({
|
||||
RETRY_CONFIG: {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000,
|
||||
timeout: 30000
|
||||
},
|
||||
USER_AGENT: 'Mozilla/5.0 (compatible; ApartmentScraper/1.0)'
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
const { fetchPage } = require('../../services/scraperService');
|
||||
const config = require('../../config/scraper');
|
||||
|
||||
describe('fetchPage', () => {
|
||||
let mockLogger;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Create mock logger
|
||||
mockLogger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn()
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('successful requests', () => {
|
||||
it('should return HTML string on success', async () => {
|
||||
const htmlContent = '<html><body>Test content</body></html>';
|
||||
|
||||
axios.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: htmlContent
|
||||
});
|
||||
|
||||
const result = await fetchPage('https://example.com', mockLogger);
|
||||
|
||||
expect(result).toBe(htmlContent);
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should use axios for HTTP GET', async () => {
|
||||
const url = 'https://example.com/apartments';
|
||||
|
||||
axios.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: '<html></html>'
|
||||
});
|
||||
|
||||
await fetchPage(url, mockLogger);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(url, expect.any(Object));
|
||||
});
|
||||
|
||||
it('should set timeout from config', async () => {
|
||||
axios.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: '<html></html>'
|
||||
});
|
||||
|
||||
await fetchPage('https://example.com', mockLogger);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
timeout: config.RETRY_CONFIG.timeout
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should set User-Agent header from config', async () => {
|
||||
axios.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: '<html></html>'
|
||||
});
|
||||
|
||||
await fetchPage('https://example.com', mockLogger);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'User-Agent': config.USER_AGENT
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should log the attempt with attempt number', async () => {
|
||||
axios.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: '<html></html>'
|
||||
});
|
||||
|
||||
await fetchPage('https://example.com', mockLogger);
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Fetching page',
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com',
|
||||
attempt: 1
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should log successful fetch with status and content length', async () => {
|
||||
const htmlContent = '<html><body>Content</body></html>';
|
||||
|
||||
axios.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: htmlContent
|
||||
});
|
||||
|
||||
await fetchPage('https://example.com', mockLogger);
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Page fetched successfully',
|
||||
expect.objectContaining({
|
||||
status: 200,
|
||||
contentLength: htmlContent.length
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry behavior on 5xx errors', () => {
|
||||
it('should retry on 500 Internal Server Error', async () => {
|
||||
const error500 = new Error('Internal Server Error');
|
||||
error500.response = { status: 500 };
|
||||
|
||||
axios.get
|
||||
.mockRejectedValueOnce(error500)
|
||||
.mockResolvedValueOnce({ status: 200, data: '<html></html>' });
|
||||
|
||||
const promise = fetchPage('https://example.com', mockLogger);
|
||||
|
||||
// Fast-forward through the retry delay
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe('<html></html>');
|
||||
expect(axios.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on 502 Bad Gateway', async () => {
|
||||
const error502 = new Error('Bad Gateway');
|
||||
error502.response = { status: 502 };
|
||||
|
||||
axios.get
|
||||
.mockRejectedValueOnce(error502)
|
||||
.mockResolvedValueOnce({ status: 200, data: '<html></html>' });
|
||||
|
||||
const promise = fetchPage('https://example.com', mockLogger);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on 503 Service Unavailable', async () => {
|
||||
const error503 = new Error('Service Unavailable');
|
||||
error503.response = { status: 503 };
|
||||
|
||||
axios.get
|
||||
.mockRejectedValueOnce(error503)
|
||||
.mockResolvedValueOnce({ status: 200, data: '<html></html>' });
|
||||
|
||||
const promise = fetchPage('https://example.com', mockLogger);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on 504 Gateway Timeout', async () => {
|
||||
const error504 = new Error('Gateway Timeout');
|
||||
error504.response = { status: 504 };
|
||||
|
||||
axios.get
|
||||
.mockRejectedValueOnce(error504)
|
||||
.mockResolvedValueOnce({ status: 200, data: '<html></html>' });
|
||||
|
||||
const promise = fetchPage('https://example.com', mockLogger);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry behavior on network errors', () => {
|
||||
it('should retry on network timeout (ECONNABORTED)', async () => {
|
||||
const timeoutError = new Error('timeout of 30000ms exceeded');
|
||||
timeoutError.code = 'ECONNABORTED';
|
||||
// Network errors don't have a response property
|
||||
|
||||
axios.get
|
||||
.mockRejectedValueOnce(timeoutError)
|
||||
.mockResolvedValueOnce({ status: 200, data: '<html></html>' });
|
||||
|
||||
const promise = fetchPage('https://example.com', mockLogger);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe('<html></html>');
|
||||
expect(axios.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on DNS resolution failure (ENOTFOUND)', async () => {
|
||||
const dnsError = new Error('getaddrinfo ENOTFOUND');
|
||||
dnsError.code = 'ENOTFOUND';
|
||||
|
||||
axios.get
|
||||
.mockRejectedValueOnce(dnsError)
|
||||
.mockResolvedValueOnce({ status: 200, data: '<html></html>' });
|
||||
|
||||
const promise = fetchPage('https://example.com', mockLogger);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on connection refused (ECONNREFUSED)', async () => {
|
||||
const connError = new Error('connect ECONNREFUSED');
|
||||
connError.code = 'ECONNREFUSED';
|
||||
|
||||
axios.get
|
||||
.mockRejectedValueOnce(connError)
|
||||
.mockResolvedValueOnce({ status: 200, data: '<html></html>' });
|
||||
|
||||
const promise = fetchPage('https://example.com', mockLogger);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no retry on 4xx errors', () => {
|
||||
it('should NOT retry on 400 Bad Request', async () => {
|
||||
const error400 = new Error('Bad Request');
|
||||
error400.response = { status: 400 };
|
||||
|
||||
axios.get.mockRejectedValueOnce(error400);
|
||||
|
||||
await expect(fetchPage('https://example.com', mockLogger))
|
||||
.rejects.toThrow();
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT retry on 401 Unauthorized', async () => {
|
||||
const error401 = new Error('Unauthorized');
|
||||
error401.response = { status: 401 };
|
||||
|
||||
axios.get.mockRejectedValueOnce(error401);
|
||||
|
||||
await expect(fetchPage('https://example.com', mockLogger))
|
||||
.rejects.toThrow();
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT retry on 403 Forbidden', async () => {
|
||||
const error403 = new Error('Forbidden');
|
||||
error403.response = { status: 403 };
|
||||
|
||||
axios.get.mockRejectedValueOnce(error403);
|
||||
|
||||
await expect(fetchPage('https://example.com', mockLogger))
|
||||
.rejects.toThrow();
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT retry on 404 Not Found', async () => {
|
||||
const error404 = new Error('Not Found');
|
||||
error404.response = { status: 404 };
|
||||
|
||||
axios.get.mockRejectedValueOnce(error404);
|
||||
|
||||
await expect(fetchPage('https://example.com', mockLogger))
|
||||
.rejects.toThrow();
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT retry on 429 Too Many Requests', async () => {
|
||||
const error429 = new Error('Too Many Requests');
|
||||
error429.response = { status: 429 };
|
||||
|
||||
axios.get.mockRejectedValueOnce(error429);
|
||||
|
||||
await expect(fetchPage('https://example.com', mockLogger))
|
||||
.rejects.toThrow();
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exponential backoff', () => {
|
||||
it('should use exponential backoff delays: 1s, 2s, 4s', async () => {
|
||||
const error500 = new Error('Internal Server Error');
|
||||
error500.response = { status: 500 };
|
||||
|
||||
// Fail 3 times, then succeed
|
||||
axios.get
|
||||
.mockRejectedValueOnce(error500)
|
||||
.mockRejectedValueOnce(error500)
|
||||
.mockRejectedValueOnce(error500)
|
||||
.mockResolvedValueOnce({ status: 200, data: '<html></html>' });
|
||||
|
||||
const promise = fetchPage('https://example.com', mockLogger);
|
||||
|
||||
// First retry: 1 second delay
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Second retry: 2 second delay
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
|
||||
// Third retry: 4 second delay
|
||||
await jest.advanceTimersByTimeAsync(4000);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('should log each retry attempt with attempt number', async () => {
|
||||
const error500 = new Error('Internal Server Error');
|
||||
error500.response = { status: 500 };
|
||||
|
||||
axios.get
|
||||
.mockRejectedValueOnce(error500)
|
||||
.mockRejectedValueOnce(error500)
|
||||
.mockResolvedValueOnce({ status: 200, data: '<html></html>' });
|
||||
|
||||
const promise = fetchPage('https://example.com', mockLogger);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
|
||||
await promise;
|
||||
|
||||
// Check that attempt numbers were logged
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Fetching page',
|
||||
expect.objectContaining({ attempt: 1 })
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Fetching page',
|
||||
expect.objectContaining({ attempt: 2 })
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Fetching page',
|
||||
expect.objectContaining({ attempt: 3 })
|
||||
);
|
||||
});
|
||||
|
||||
it('should log failed attempts with error details', async () => {
|
||||
const error500 = new Error('Internal Server Error');
|
||||
error500.response = { status: 500 };
|
||||
|
||||
axios.get
|
||||
.mockRejectedValueOnce(error500)
|
||||
.mockResolvedValueOnce({ status: 200, data: '<html></html>' });
|
||||
|
||||
const promise = fetchPage('https://example.com', mockLogger);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
'Fetch attempt failed',
|
||||
expect.objectContaining({
|
||||
attempt: 1,
|
||||
statusCode: 500,
|
||||
isRetryable: true
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should log wait time before retry', async () => {
|
||||
const error500 = new Error('Internal Server Error');
|
||||
error500.response = { status: 500 };
|
||||
|
||||
axios.get
|
||||
.mockRejectedValueOnce(error500)
|
||||
.mockResolvedValueOnce({ status: 200, data: '<html></html>' });
|
||||
|
||||
const promise = fetchPage('https://example.com', mockLogger);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Waiting before retry',
|
||||
expect.objectContaining({ delay: 1000 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry exhaustion', () => {
|
||||
// Use real timers for exhaustion tests to avoid promise handling issues
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
it('should throw error after all retries exhausted (4 attempts total)', async () => {
|
||||
// Override config for faster tests
|
||||
const originalBaseDelay = config.RETRY_CONFIG.baseDelay;
|
||||
config.RETRY_CONFIG.baseDelay = 1; // 1ms instead of 1000ms
|
||||
|
||||
try {
|
||||
const error500 = new Error('Internal Server Error');
|
||||
error500.response = { status: 500 };
|
||||
|
||||
// All 4 attempts fail (1 initial + 3 retries)
|
||||
axios.get
|
||||
.mockRejectedValueOnce(error500)
|
||||
.mockRejectedValueOnce(error500)
|
||||
.mockRejectedValueOnce(error500)
|
||||
.mockRejectedValueOnce(error500);
|
||||
|
||||
await expect(fetchPage('https://example.com', mockLogger))
|
||||
.rejects.toThrow('Internal Server Error');
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(4);
|
||||
} finally {
|
||||
config.RETRY_CONFIG.baseDelay = originalBaseDelay;
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw the last error when retries are exhausted', async () => {
|
||||
// Override config for faster tests
|
||||
const originalBaseDelay = config.RETRY_CONFIG.baseDelay;
|
||||
config.RETRY_CONFIG.baseDelay = 1; // 1ms instead of 1000ms
|
||||
|
||||
try {
|
||||
const error500 = new Error('Server Error');
|
||||
error500.response = { status: 500 };
|
||||
|
||||
axios.get.mockRejectedValue(error500);
|
||||
|
||||
await expect(fetchPage('https://example.com', mockLogger))
|
||||
.rejects.toBe(error500);
|
||||
} finally {
|
||||
config.RETRY_CONFIG.baseDelay = originalBaseDelay;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('axios configuration', () => {
|
||||
it('should set maxRedirects to 5', async () => {
|
||||
axios.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: '<html></html>'
|
||||
});
|
||||
|
||||
await fetchPage('https://example.com', mockLogger);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
maxRedirects: 5
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept 2xx and 3xx status codes via validateStatus', async () => {
|
||||
axios.get.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: '<html></html>'
|
||||
});
|
||||
|
||||
await fetchPage('https://example.com', mockLogger);
|
||||
|
||||
const callArgs = axios.get.mock.calls[0][1];
|
||||
const validateStatus = callArgs.validateStatus;
|
||||
|
||||
// Should accept 2xx
|
||||
expect(validateStatus(200)).toBe(true);
|
||||
expect(validateStatus(201)).toBe(true);
|
||||
expect(validateStatus(204)).toBe(true);
|
||||
|
||||
// Should accept 3xx
|
||||
expect(validateStatus(301)).toBe(true);
|
||||
expect(validateStatus(302)).toBe(true);
|
||||
|
||||
// Should reject 4xx and 5xx
|
||||
expect(validateStatus(400)).toBe(false);
|
||||
expect(validateStatus(404)).toBe(false);
|
||||
expect(validateStatus(500)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user