odesli.js
Version:
Node.js Client to query odesli.co (song.link/album.link) API
743 lines (674 loc) • 25.7 kB
JavaScript
jest.mock('node-fetch');
const fetch = require('node-fetch');
const Odesli = require('../lib/index.js');
// Helper to create a mock fetch response
function mockFetchResponse(data, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: jest.fn().mockResolvedValue(data),
};
}
describe('Odesli', () => {
let odesli;
beforeEach(() => {
// Disable caching for tests to avoid interference
odesli = new Odesli({ cache: false });
fetch.mockReset();
});
describe('Constructor', () => {
test('should create instance with default options', () => {
const instance = new Odesli();
expect(instance.apiKey).toBeUndefined();
expect(instance.version).toBe('v1-alpha.1');
});
test('should create instance with custom options', () => {
const instance = new Odesli({
apiKey: 'test-api-key',
version: 'v2-beta',
});
expect(instance.apiKey).toBe('test-api-key');
expect(instance.version).toBe('v2-beta');
});
test('should create instance with partial options', () => {
const instance = new Odesli({ apiKey: 'test-key' });
expect(instance.apiKey).toBe('test-key');
expect(instance.version).toBe('v1-alpha.1');
});
test('should handle empty options object', () => {
const instance = new Odesli({});
expect(instance.apiKey).toBeUndefined();
expect(instance.version).toBe('v1-alpha.1');
});
test('should handle null/undefined options', () => {
const instance1 = new Odesli(null);
const instance2 = new Odesli(undefined);
expect(instance1.apiKey).toBeUndefined();
expect(instance1.version).toBe('v1-alpha.1');
expect(instance2.apiKey).toBeUndefined();
expect(instance2.version).toBe('v1-alpha.1');
});
});
describe('_request method', () => {
test('should make request without API key', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::123',
title: 'Test Song',
artist: ['Test Artist'],
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const result = await odesli._request('test-path');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/test-path',
expect.objectContaining({
signal: expect.any(AbortSignal),
headers: expect.objectContaining({
'User-Agent': expect.any(String),
Accept: 'application/json',
}),
})
);
expect(result).toEqual(mockResponse);
});
test('should make request with API key', async () => {
const odesliWithKey = new Odesli({ apiKey: 'test-key', cache: false });
const mockResponse = { success: true };
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
await odesliWithKey._request('test-path');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/test-path&key=test-key',
expect.any(Object)
);
});
test('should handle rate limiting error', async () => {
const errorResponse = { statusCode: 429, code: 'RATE_LIMITED' };
fetch.mockResolvedValueOnce(mockFetchResponse(errorResponse, 200));
await expect(odesli._request('test-path')).rejects.toThrow(
'429: RATE_LIMITED, You are being rate limited, No API Key is 10 Requests / Minute.'
);
});
test('should handle 4xx errors', async () => {
const errorResponse = { statusCode: 400, code: 'BAD_REQUEST' };
fetch.mockResolvedValueOnce(mockFetchResponse(errorResponse, 200));
await expect(odesli._request('test-path')).rejects.toThrow(
'400: BAD_REQUEST, Codes in the 4xx range indicate an error that failed given the information provided.'
);
});
test('should handle 5xx errors', async () => {
const errorResponse = { statusCode: 500, code: 'INTERNAL_ERROR' };
fetch.mockResolvedValueOnce(mockFetchResponse(errorResponse, 200));
await expect(odesli._request('test-path')).rejects.toThrow(
"500: INTERNAL_ERROR, Codes in the 5xx range indicate an error with Songlink's servers."
);
});
test('should handle unexpected API response', async () => {
fetch.mockResponseOnce('invalid json');
await expect(odesli._request('test-path')).rejects.toThrow(
'invalid json response body'
);
});
test('should handle network errors', async () => {
fetch.mockReject(new Error('Network error'));
await expect(odesli._request('test-path')).rejects.toThrow(
'Network error'
);
});
test('should handle empty path', async () => {
const mockResponse = { success: true };
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
await odesli._request('');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/',
expect.any(Object)
);
});
test('should handle special characters in path', async () => {
const mockResponse = { success: true };
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
await odesli._request('test/path?param=value&other=123');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/test/path?param=value&other=123',
expect.any(Object)
);
});
});
describe('fetch method', () => {
test('should fetch song by URL', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::123',
entitiesByUniqueId: {
'SPOTIFY_SONG::123': {
id: '123',
title: 'Test Song',
artistName: 'Test Artist, Featured Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const result = await odesli.fetch('https://open.spotify.com/track/123');
expect(result.title).toBe('Test Song');
expect(result.artist).toEqual(['Test Artist', 'Featured Artist']);
expect(result.type).toBe('song');
expect(result.thumbnail).toBe('https://example.com/thumb.jpg');
});
test('should fetch multiple songs by array of URLs', async () => {
const mockResponse1 = {
entityUniqueId: 'SPOTIFY_SONG::123',
entitiesByUniqueId: {
'SPOTIFY_SONG::123': {
id: '123',
title: 'Test Song 1',
artistName: 'Test Artist 1',
type: 'song',
thumbnailUrl: 'https://example.com/thumb1.jpg',
},
},
};
const mockResponse2 = {
entityUniqueId: 'SPOTIFY_SONG::456',
entitiesByUniqueId: {
'SPOTIFY_SONG::456': {
id: '456',
title: 'Test Song 2',
artistName: 'Test Artist 2',
type: 'song',
thumbnailUrl: 'https://example.com/thumb2.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse1));
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse2));
const urls = [
'https://open.spotify.com/track/123',
'https://open.spotify.com/track/456',
];
const results = await odesli.fetch(urls);
expect(Array.isArray(results)).toBe(true);
expect(results).toHaveLength(2);
expect(results[0].title).toBe('Test Song 1');
expect(results[1].title).toBe('Test Song 2');
expect(fetch).toHaveBeenCalledTimes(2);
});
test('should handle errors in batch fetch', async () => {
const urls = [
'https://open.spotify.com/track/123',
'https://open.spotify.com/track/invalid',
];
fetch.mockResponseOnce(
JSON.stringify({
entityUniqueId: 'SPOTIFY_SONG::123',
entitiesByUniqueId: {
'SPOTIFY_SONG::123': { title: 'Test Song' },
},
})
);
fetch.mockRejectOnce(new Error('Network error'));
const results = await odesli.fetch(urls);
expect(Array.isArray(results)).toBe(true);
expect(results).toHaveLength(2);
expect(results[0].title).toBe('Test Song');
expect(results[1].error).toBe('Network error');
});
test('should fetch song with custom country', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::456',
entitiesByUniqueId: {
'SPOTIFY_SONG::456': {
id: '456',
title: 'Test Song 2',
artistName: 'Test Artist 2',
type: 'song',
thumbnailUrl: 'https://example.com/thumb2.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
await odesli.fetch('https://open.spotify.com/track/456', 'GB');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/links?url=https%3A%2F%2Fopen.spotify.com%2Ftrack%2F456&userCountry=GB',
expect.any(Object)
);
});
test('should fetch batch with options', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::789',
entitiesByUniqueId: {
'SPOTIFY_SONG::789': {
id: '789',
title: 'Test Song 3',
artistName: 'Test Artist 3',
type: 'song',
thumbnailUrl: 'https://example.com/thumb3.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const urls = ['https://open.spotify.com/track/789'];
const results = await odesli.fetch(urls, {
country: 'CA',
concurrency: 1,
skipCache: true,
});
expect(Array.isArray(results)).toBe(true);
expect(results[0].title).toBe('Test Song 3');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/links?url=https%3A%2F%2Fopen.spotify.com%2Ftrack%2F789&userCountry=CA',
expect.any(Object)
);
});
test('should throw error when no URL provided', async () => {
await expect(odesli.fetch()).rejects.toThrow(
'No URL was provided to odesli.fetch()'
);
});
test('should throw error when URL is null', async () => {
await expect(odesli.fetch(null)).rejects.toThrow(
'No URL was provided to odesli.fetch()'
);
});
test('should return empty array when URLs array is empty', async () => {
const results = await odesli.fetch([]);
expect(Array.isArray(results)).toBe(true);
expect(results).toHaveLength(0);
});
test('should throw error when URLs is not an array', async () => {
await expect(odesli.fetch('not-an-array')).rejects.toThrow(
'Invalid URL format provided to odesli.fetch()'
);
});
});
describe('getByParams method', () => {
test('should get song by parameters', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::301',
entitiesByUniqueId: {
'SPOTIFY_SONG::301': {
id: '301',
title: 'Param Song',
artistName: 'Param Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb5.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const result = await odesli.getByParams('spotify', 'song', '301');
expect(result.title).toBe('Param Song');
expect(result.artist).toEqual(['Param Artist']);
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/links?platform=spotify&type=song&id=301&userCountry=US',
expect.any(Object)
);
});
test('should handle full ID format', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::302',
entitiesByUniqueId: {
'SPOTIFY_SONG::302': {
id: '302',
title: 'Full ID Song',
artistName: 'Full ID Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb6.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
await odesli.getByParams('spotify', 'song', 'SPOTIFY_SONG::302');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/links?platform=spotify&type=song&id=302&userCountry=US',
expect.any(Object)
);
});
test('should throw error when platform missing', async () => {
await expect(odesli.getByParams(null, 'song', '123')).rejects.toThrow(
'No `platform` was provided to odesli.getByParams()'
);
});
test('should throw error when type missing', async () => {
await expect(odesli.getByParams('spotify', null, '123')).rejects.toThrow(
'No `type` was provided to odesli.getByParams()'
);
});
test('should throw error when id missing', async () => {
await expect(odesli.getByParams('spotify', 'song', null)).rejects.toThrow(
'No `id` was provided to odesli.getByParams()'
);
});
test('should handle different platforms', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::123',
entitiesByUniqueId: {
'SPOTIFY_SONG::123': {
id: '123',
title: 'Test Song',
artistName: 'Test Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
await odesli.getByParams('spotify', 'song', '123');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/links?platform=spotify&type=song&id=123&userCountry=US',
expect.any(Object)
);
});
test('should handle different types', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_ALBUM::308',
entitiesByUniqueId: {
'SPOTIFY_ALBUM::308': {
id: '308',
title: 'Test Album',
artistName: 'Test Artist',
type: 'album',
thumbnailUrl: 'https://example.com/thumb.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const result = await odesli.getByParams('spotify', 'album', '308');
expect(result.type).toBe('album');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/links?platform=spotify&type=album&id=308&userCountry=US',
expect.any(Object)
);
});
test('should handle special characters in ID', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::309',
entitiesByUniqueId: {
'SPOTIFY_SONG::309': {
id: '309',
title: 'Special Char Song',
artistName: 'Special Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
await odesli.getByParams('spotify', 'song', '123-456_789');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/links?platform=spotify&type=song&id=123-456_789&userCountry=US',
expect.any(Object)
);
});
});
describe('getById method', () => {
test('should get song by entity ID', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::401',
entitiesByUniqueId: {
'SPOTIFY_SONG::401': {
id: '401',
title: 'Entity ID Song',
artistName: 'Entity Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb7.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const result = await odesli.getById('SPOTIFY_SONG::401');
expect(result.title).toBe('Entity ID Song');
expect(result.artist).toEqual(['Entity Artist']);
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/links?platform=spotify&type=song&id=401&userCountry=US',
expect.any(Object)
);
});
test('should throw error when no ID provided', async () => {
await expect(odesli.getById()).rejects.toThrow(
'No `id` was provided to odesli.getById()'
);
});
test('should throw error when ID format is invalid', async () => {
await expect(odesli.getById('invalid-id')).rejects.toThrow(
'Provided Entity ID Does not match format. `<PLATFORM>_<SONG|ALBUM>::<UNIQUEID>`'
);
});
test('should handle different platform formats', async () => {
const mockResponse = {
entityUniqueId: 'APPLEMUSIC_SONG::402',
entitiesByUniqueId: {
'APPLEMUSIC_SONG::402': {
id: '402',
title: 'Apple Music Song',
artistName: 'Apple Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb8.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const result = await odesli.getById('APPLEMUSIC_SONG::402');
expect(result.title).toBe('Apple Music Song');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/links?platform=applemusic&type=song&id=402&userCountry=US',
expect.any(Object)
);
});
test('should handle album entity IDs', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_ALBUM::403',
entitiesByUniqueId: {
'SPOTIFY_ALBUM::403': {
id: '403',
title: 'Entity Album',
artistName: 'Entity Artist',
type: 'album',
thumbnailUrl: 'https://example.com/thumb9.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const result = await odesli.getById('SPOTIFY_ALBUM::403');
expect(result.type).toBe('album');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/links?platform=spotify&type=album&id=403&userCountry=US',
expect.any(Object)
);
});
test('should handle case insensitive platform names', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::404',
entitiesByUniqueId: {
'SPOTIFY_SONG::404': {
id: '404',
title: 'Case Insensitive Song',
artistName: 'Case Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb10.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const result = await odesli.getById('spotify_song::404');
expect(result.title).toBe('Case Insensitive Song');
expect(fetch).toHaveBeenCalledWith(
'https://api.song.link/v1-alpha.1/links?platform=spotify&type=song&id=404&userCountry=US',
expect.any(Object)
);
});
});
describe('Edge Cases and Error Scenarios', () => {
test('should handle malformed API response', async () => {
const malformedResponse = {
entityUniqueId: 'SPOTIFY_SONG::501',
entitiesByUniqueId: {
'SPOTIFY_SONG::501': {
// Missing required fields
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(malformedResponse));
const result = await odesli.fetch('https://open.spotify.com/track/501');
expect(result.title).toBeUndefined();
expect(result.artist).toBeUndefined();
});
test('should handle multiple entities in response', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::502',
entitiesByUniqueId: {
'SPOTIFY_SONG::502': {
id: '502',
title: 'Main Song',
artistName: 'Main Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb.jpg',
},
'APPLEMUSIC_SONG::503': {
id: '503',
title: 'Same Song',
artistName: 'Same Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb2.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const result = await odesli.fetch('https://open.spotify.com/track/502');
expect(result.title).toBe('Main Song');
// Should only process the main entity, not all entities
});
test('should handle empty entitiesByUniqueId', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::504',
entitiesByUniqueId: {},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const result = await odesli.fetch('https://open.spotify.com/track/504');
expect(result.title).toBeUndefined();
});
test('should handle missing entityUniqueId in response', async () => {
const mockResponse = {
entitiesByUniqueId: {
'SPOTIFY_SONG::505': {
id: '505',
title: 'Missing Entity Song',
artistName: 'Missing Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const result = await odesli.fetch('https://open.spotify.com/track/505');
expect(result.title).toBeUndefined();
});
});
describe('getCountryOptions method', () => {
test('should return array of country options', () => {
const countryOptions = Odesli.getCountryOptions();
expect(Array.isArray(countryOptions)).toBe(true);
expect(countryOptions.length).toBeGreaterThan(0);
});
test('should return objects with code and name properties', () => {
const countryOptions = Odesli.getCountryOptions();
const firstCountry = countryOptions[0];
expect(firstCountry).toHaveProperty('code');
expect(firstCountry).toHaveProperty('name');
expect(typeof firstCountry.code).toBe('string');
expect(typeof firstCountry.name).toBe('string');
});
test('should include popular countries', () => {
const countryOptions = Odesli.getCountryOptions();
const codes = countryOptions.map(c => c.code);
expect(codes).toContain('US');
expect(codes).toContain('GB');
expect(codes).toContain('CA');
expect(codes).toContain('AU');
expect(codes).toContain('DE');
});
test('should have valid ISO 3166-1 alpha-2 codes', () => {
const countryOptions = Odesli.getCountryOptions();
countryOptions.forEach(country => {
expect(country.code).toMatch(/^[A-Z]{2}$/);
});
});
test('should have unique country codes', () => {
const countryOptions = Odesli.getCountryOptions();
const codes = countryOptions.map(c => c.code);
const uniqueCodes = [...new Set(codes)];
expect(codes).toHaveLength(uniqueCodes.length);
});
test('should have non-empty country names', () => {
const countryOptions = Odesli.getCountryOptions();
countryOptions.forEach(country => {
expect(country.name.trim().length).toBeGreaterThan(0);
});
});
});
describe('Country code validation', () => {
test('should accept valid country codes', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::123',
entitiesByUniqueId: {
'SPOTIFY_SONG::123': {
id: '123',
title: 'Test Song',
artistName: 'Test Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb.jpg',
},
},
};
fetch.mockResolvedValueOnce(mockFetchResponse(mockResponse));
const result = await odesli.fetch('https://open.spotify.com/track/123', {
country: 'US',
});
expect(result.title).toBe('Test Song');
});
test('should accept multiple valid country codes', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::123',
entitiesByUniqueId: {
'SPOTIFY_SONG::123': {
id: '123',
title: 'Test Song',
artistName: 'Test Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb.jpg',
},
},
};
fetch.mockResolvedValue(mockFetchResponse(mockResponse));
const validCountries = ['US', 'GB', 'CA', 'AU', 'DE', 'FR', 'JP'];
for (const country of validCountries) {
await odesli.fetch('https://open.spotify.com/track/123', {
country,
});
}
// Should have made requests for each country
expect(fetch).toHaveBeenCalledTimes(validCountries.length);
});
test('should handle batch requests with country codes', async () => {
const mockResponse = {
entityUniqueId: 'SPOTIFY_SONG::123',
entitiesByUniqueId: {
'SPOTIFY_SONG::123': {
id: '123',
title: 'Test Song',
artistName: 'Test Artist',
type: 'song',
thumbnailUrl: 'https://example.com/thumb.jpg',
},
},
};
fetch.mockResolvedValue(mockFetchResponse(mockResponse));
const urls = [
'https://open.spotify.com/track/123',
'https://music.apple.com/us/album/test/456?i=789',
];
const results = await odesli.fetch(urls, { country: 'GB' });
expect(results).toHaveLength(2);
expect(fetch).toHaveBeenCalledTimes(2);
});
});
});