@andreasnicolaou/overpass-client
Version:
A wrapper for the Overpass API to query OpenStreetMap data.
401 lines (400 loc) • 15.8 kB
JavaScript
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { take, tap } from 'rxjs/operators';
import { OverpassClient } from './index';
describe('OverpassClient', () => {
let axiosMockAdapter;
let overpassClient;
beforeEach(() => {
axiosMockAdapter = new MockAdapter(axios);
overpassClient = new OverpassClient('http://test-overpass-api', 'json', 60, 2);
// eslint-disable-next-line @typescript-eslint/no-empty-function
jest.spyOn(console, 'warn').mockImplementation(() => { }); // Disable console warnings
});
afterEach(() => {
axiosMockAdapter.reset();
jest.restoreAllMocks();
});
test('should successfully fetch a node', (done) => {
const mockData = { elements: [{ id: 123, type: 'node', lat: 48.85, lon: 2.35 }] };
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElement('node', 123)
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
done();
},
error: done.fail,
});
});
test('should handle API failure and retry', (done) => {
axiosMockAdapter.onPost('').reply(500);
overpassClient
.getElement('node', 123)
.pipe(take(1))
.subscribe({
next: () => done.fail(new Error('Expected an error, but got success')),
error: (err) => {
expect(err).toBeDefined();
expect(err.message).toContain('Max retries exceeded');
done();
},
});
});
test('should fetch cafe amenities by bounding box', (done) => {
const mockData = { elements: [{ id: 1, type: 'node', lat: 48.85, lon: 2.35, tags: { amenity: 'cafe' } }] };
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElementsByBoundingBox({ amenity: ['cafe'] }, [48.85, 2.29, 48.87, 2.35])
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
done();
},
error: done.fail,
});
});
test('should fetch restaurant amenities by radius', (done) => {
const mockData = { elements: [{ id: 2, type: 'node', lat: 48.85, lon: 2.35, tags: { amenity: 'restaurant' } }] };
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElementsByRadius({ amenity: ['restaurant'] }, 48.85, 2.35, 500)
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
done();
},
error: done.fail,
});
});
test('should successfully fetch a way', (done) => {
const mockData = { elements: [{ id: 456, type: 'way', nodes: [1, 2, 3] }] };
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElement('way', 456)
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
done();
},
error: done.fail,
});
});
test('should handle invalid query format (400)', (done) => {
axiosMockAdapter.onPost('').reply(400, { error: 'Bad query format' });
overpassClient
.getElement('node', 123)
.pipe(take(1))
.subscribe({
next: () => done.fail(new Error('Expected an error, but got success')),
error: (err) => {
expect(err).toBeDefined();
expect(err.message).toContain('Overpass Error');
done();
},
});
});
test('should not retry on success', (done) => {
const mockData = { elements: [{ id: 123, type: 'node', lat: 48.85, lon: 2.35 }] };
const spy = jest.fn();
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElement('node', 123)
.pipe(take(1), tap(spy))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
expect(spy).toHaveBeenCalledTimes(1);
done();
},
error: done.fail,
});
});
test('should retry on failure with exponential backoff', (done) => {
const mockData = { elements: [{ id: 123, type: 'node', lat: 48.85, lon: 2.35 }] };
const spy = jest.fn();
axiosMockAdapter.onPost('').replyOnce(429);
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElement('node', 123)
.pipe(take(1), tap(spy))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
expect(spy).toHaveBeenCalledTimes(1);
done();
},
error: done.fail,
});
});
test('should return cached data for repeated requests', (done) => {
const mockData = { elements: [{ id: 123, type: 'node', lat: 48.85, lon: 2.35 }] };
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElement('node', 123)
.pipe(take(1))
.subscribe({
next: () => {
overpassClient
.getElement('node', 123)
.pipe(take(1))
.subscribe({
next: (cachedData) => {
expect(cachedData).toEqual(mockData);
done();
},
error: done.fail,
});
},
error: done.fail,
});
});
test('should clear cache', () => {
// First make a request to populate cache
const mockData = { elements: [{ id: 123, type: 'node', lat: 48.85, lon: 2.35 }] };
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient.getElement('node', 123).pipe(take(1)).subscribe();
// Clear the cache
overpassClient.clearCache();
// Verify cache is cleared by checking the internal state
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(overpassClient.lruCache.size).toBe(0);
});
test('should return cached data for repeated bounding box requests', (done) => {
const mockData = { elements: [{ id: 1, type: 'node', lat: 48.85, lon: 2.35, tags: { amenity: 'cafe' } }] };
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElementsByBoundingBox({ amenity: ['cafe'] }, [48.85, 2.29, 48.87, 2.35])
.pipe(take(1))
.subscribe({
next: () => {
// Second request should hit cache
overpassClient
.getElementsByBoundingBox({ amenity: ['cafe'] }, [48.85, 2.29, 48.87, 2.35])
.pipe(take(1))
.subscribe({
next: (cachedData) => {
expect(cachedData).toEqual(mockData);
done();
},
error: done.fail,
});
},
error: done.fail,
});
});
test('should return cached data for repeated radius requests', (done) => {
const mockData = { elements: [{ id: 2, type: 'node', lat: 48.85, lon: 2.35, tags: { amenity: 'restaurant' } }] };
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElementsByRadius({ amenity: ['restaurant'] }, 48.85, 2.35, 500)
.pipe(take(1))
.subscribe({
next: () => {
// Second request should hit cache
overpassClient
.getElementsByRadius({ amenity: ['restaurant'] }, 48.85, 2.35, 500)
.pipe(take(1))
.subscribe({
next: (cachedData) => {
expect(cachedData).toEqual(mockData);
done();
},
error: done.fail,
});
},
error: done.fail,
});
});
test('should handle 503 Service Unavailable errors with retry', (done) => {
const mockData = { elements: [{ id: 123, type: 'node', lat: 48.85, lon: 2.35 }] };
axiosMockAdapter.onPost('').replyOnce(503);
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElement('node', 123)
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
done();
},
error: done.fail,
});
});
test('should handle 504 Gateway Timeout errors with retry', (done) => {
const mockData = { elements: [{ id: 123, type: 'node', lat: 48.85, lon: 2.35 }] };
axiosMockAdapter.onPost('').replyOnce(504);
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElement('node', 123)
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
done();
},
error: done.fail,
});
});
test('should handle unknown HTTP status codes', (done) => {
axiosMockAdapter.onPost('').reply(418, 'I am a teapot', { status: 418, statusText: 'I am a teapot' });
overpassClient
.getElement('node', 123)
.pipe(take(1))
.subscribe({
next: () => done.fail(new Error('Expected an error, but got success')),
error: (err) => {
expect(err).toBeDefined();
expect(err.message).toContain('[418]');
expect(err.message).toContain('Unknown error occured');
done();
},
});
});
test('should handle network errors without response', (done) => {
axiosMockAdapter.onPost('').networkError();
overpassClient
.getElement('node', 123)
.pipe(take(1))
.subscribe({
next: () => done.fail(new Error('Expected an error, but got success')),
error: (err) => {
expect(err).toBeDefined();
expect(err.message).toContain('Something went wrong');
done();
},
});
});
test('should handle 400 errors with detailed error parsing', (done) => {
const errorHtml = '<p><strong>Error</strong>: Invalid syntax "test" </p>';
axiosMockAdapter.onPost('').reply(400, errorHtml);
overpassClient
.getElement('node', 123)
.pipe(take(1))
.subscribe({
next: () => done.fail(new Error('Expected an error, but got success')),
error: (err) => {
expect(err).toBeDefined();
expect(err.message).toContain('Bad Request Error');
expect(err.message).toContain('Invalid syntax "test"');
done();
},
});
});
test('should handle 429 Rate Limit with retry-after header', (done) => {
const mockData = { elements: [{ id: 123, type: 'node', lat: 48.85, lon: 2.35 }] };
axiosMockAdapter.onPost('').replyOnce(429, '', { 'retry-after': '1' });
axiosMockAdapter.onPost('').reply(200, mockData);
const consoleWarnSpy = jest.spyOn(console, 'warn');
overpassClient
.getElement('node', 123)
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('rate limit reached'));
done();
},
error: done.fail,
});
});
test('should handle relation element type', (done) => {
const mockData = { elements: [{ id: 789, type: 'relation', members: [] }] };
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElement('relation', 789)
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
done();
},
error: done.fail,
});
});
test('should handle multiple element types in bounding box query', (done) => {
const mockData = {
elements: [
{ id: 1, type: 'node', lat: 48.85, lon: 2.35, tags: { amenity: 'cafe' } },
{ id: 2, type: 'way', nodes: [1, 2, 3], tags: { amenity: 'cafe' } },
],
};
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElementsByBoundingBox({ amenity: ['cafe'] }, [48.85, 2.29, 48.87, 2.35], ['node', 'way'])
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
done();
},
error: done.fail,
});
});
test('should handle multiple element types in radius query', (done) => {
const mockData = {
elements: [
{ id: 1, type: 'node', lat: 48.85, lon: 2.35, tags: { amenity: 'restaurant' } },
{ id: 2, type: 'way', nodes: [1, 2, 3], tags: { amenity: 'restaurant' } },
],
};
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElementsByRadius({ amenity: ['restaurant'] }, 48.85, 2.35, 500, ['node', 'way'])
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
done();
},
error: done.fail,
});
});
test('should handle custom output format', (done) => {
const mockData = { elements: [{ id: 123, type: 'node', lat: 48.85, lon: 2.35 }] };
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElement('node', 123, 'out geom;')
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
done();
},
error: done.fail,
});
});
test('should handle bounding box query with custom output format', (done) => {
const mockData = { elements: [{ id: 1, type: 'node', lat: 48.85, lon: 2.35, tags: { amenity: 'cafe' } }] };
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElementsByBoundingBox({ amenity: ['cafe'] }, [48.85, 2.29, 48.87, 2.35], ['node'], 'out meta;')
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
done();
},
error: done.fail,
});
});
test('should handle radius query with custom output format', (done) => {
const mockData = { elements: [{ id: 2, type: 'node', lat: 48.85, lon: 2.35, tags: { amenity: 'restaurant' } }] };
axiosMockAdapter.onPost('').reply(200, mockData);
overpassClient
.getElementsByRadius({ amenity: ['restaurant'] }, 48.85, 2.35, 500, ['node'], 'out meta;')
.pipe(take(1))
.subscribe({
next: (data) => {
expect(data).toEqual(mockData);
done();
},
error: done.fail,
});
});
});