Files
bonob/tests/smapi.test.ts
2021-05-10 11:12:14 +10:00

2084 lines
66 KiB
TypeScript

import crypto from "crypto";
import request from "supertest";
import { Client, createClientAsync } from "soap";
import { v4 as uuid } from "uuid";
import { DOMParserImpl } from "xmldom-ts";
import * as xpath from "xpath-ts";
import { randomInt } from "crypto";
import { LinkCodes } from "../src/link_codes";
import makeServer, { BONOB_ACCESS_TOKEN_HEADER } from "../src/server";
import { bonobService, SONOS_DISABLED } from "../src/sonos";
import {
STRINGS_ROUTE,
LOGIN_ROUTE,
getMetadataResult,
PRESENTATION_MAP_ROUTE,
SONOS_RECOMMENDED_IMAGE_SIZES,
track,
artist,
album,
defaultAlbumArtURI,
defaultArtistArtURI,
searchResult,
} from "../src/smapi";
import {
aService,
getAppLinkMessage,
anArtist,
anAlbum,
aTrack,
someCredentials,
POP,
ROCK,
TRIP_HOP,
PUNK,
} from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service";
import supersoap from "./supersoap";
import {
albumToAlbumSummary,
artistToArtistSummary,
MusicService,
} from "../src/music_service";
import { AccessTokens } from "../src/access_tokens";
import dayjs from "dayjs";
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
describe("service config", () => {
const server = makeServer(
SONOS_DISABLED,
aService({ name: "music land" }),
"http://localhost:1234",
new InMemoryMusicService()
);
describe(STRINGS_ROUTE, () => {
it("should return xml for the strings", async () => {
const res = await request(server).get(STRINGS_ROUTE).send();
expect(res.status).toEqual(200);
// removing the sonos xml ns as makes xpath queries with xpath-ts painful
const xml = parseXML(
res.text.replace('xmlns="http://sonos.com/sonosapi"', "")
);
const sonosString = (id: string, lang: string) =>
xpath.select(
`string(/stringtables/stringtable[@xml:lang="${lang}"]/string[@stringId="${id}"])`,
xml
);
expect(sonosString("AppLinkMessage", "en-US")).toEqual(
"Linking sonos with music land"
);
expect(sonosString("AppLinkMessage", "fr-FR")).toEqual(
"Lier les sonos à la music land"
);
});
});
describe(PRESENTATION_MAP_ROUTE, () => {
it("should have an ArtWorkSizeMap for all sizes recommended by sonos", async () => {
const res = await request(server).get(PRESENTATION_MAP_ROUTE).send();
expect(res.status).toEqual(200);
// removing the sonos xml ns as makes xpath queries with xpath-ts painful
const xml = parseXML(
res.text.replace('xmlns="http://sonos.com/sonosapi"', "")
);
const imageSizeMap = (size: string) =>
xpath.select(
`string(/Presentation/PresentationMap[@type="ArtWorkSizeMap"]/Match/imageSizeMap/sizeEntry[@size="${size}"]/@substitution)`,
xml
);
SONOS_RECOMMENDED_IMAGE_SIZES.forEach((size) => {
expect(imageSizeMap(size)).toEqual(`/art/size/${size}`);
});
});
});
});
describe("getMetadataResult", () => {
describe("when there are a no mediaCollections & no mediaMetadata", () => {
it("should have zero count", () => {
const result = getMetadataResult({
index: 33,
total: 99,
});
expect(result).toEqual({
getMetadataResult: {
count: 0,
index: 33,
total: 99,
},
});
});
});
describe("when there are a number of mediaCollections", () => {
it("should add correct counts", () => {
const mediaCollection = [{}, {}];
const result = getMetadataResult({
mediaCollection,
index: 22,
total: 3,
});
expect(result).toEqual({
getMetadataResult: {
count: 2,
index: 22,
total: 3,
mediaCollection,
},
});
});
});
describe("when there are a number of mediaMetadata", () => {
it("should add correct counts", () => {
const mediaMetadata = [{}, {}];
const result = getMetadataResult({
mediaMetadata,
index: 22,
total: 3,
});
expect(result).toEqual({
getMetadataResult: {
count: 2,
index: 22,
total: 3,
mediaMetadata,
},
});
});
});
describe("when there are both a number of mediaMetadata & mediaCollections", () => {
it("should sum the counts", () => {
const mediaCollection = [{}, {}, {}];
const mediaMetadata = [{}, {}];
const result = getMetadataResult({
mediaCollection,
mediaMetadata,
index: 22,
total: 3,
});
expect(result).toEqual({
getMetadataResult: {
count: 5,
index: 22,
total: 3,
mediaCollection,
mediaMetadata,
},
});
});
});
});
describe("track", () => {
it("should map into a sonos expected track", () => {
const webAddress = "http://localhost:4567";
const accessToken = uuid();
const someTrack = aTrack({
id: uuid(),
mimeType: "audio/something",
name: "great song",
duration: randomInt(1000),
number: randomInt(100),
album: anAlbum({
name: "great album",
id: uuid(),
genre: { id: "genre101", name: "some genre" },
}),
artist: anArtist({ name: "great artist", id: uuid() }),
});
expect(track(webAddress, accessToken, someTrack)).toEqual({
itemType: "track",
id: `track:${someTrack.id}`,
mimeType: someTrack.mimeType,
title: someTrack.name,
trackMetadata: {
album: someTrack.album.name,
albumId: someTrack.album.id,
albumArtist: someTrack.artist.name,
albumArtistId: someTrack.artist.id,
albumArtURI: `${webAddress}/album/${someTrack.album.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`,
artist: someTrack.artist.name,
artistId: someTrack.artist.id,
duration: someTrack.duration,
genre: someTrack.album.genre?.name,
genreId: someTrack.album.genre?.id,
trackNumber: someTrack.number,
},
});
});
});
describe("album", () => {
it("should map to a sonos album", () => {
const webAddress = "http://localhost:9988";
const accessToken = uuid();
const someAlbum = anAlbum({ id: "id123", name: "What a great album" });
expect(album(webAddress, accessToken, someAlbum)).toEqual({
itemType: "album",
id: `album:${someAlbum.id}`,
title: someAlbum.name,
albumArtURI: defaultAlbumArtURI(webAddress, accessToken, someAlbum),
canPlay: true,
artist: someAlbum.artistName,
artistId: someAlbum.artistId,
});
});
});
describe("defaultAlbumArtURI", () => {
it("should create the correct URI", () => {
const webAddress = "http://localhost:1234";
const accessToken = uuid();
const album = anAlbum();
expect(defaultAlbumArtURI(webAddress, accessToken, album)).toEqual(
`${webAddress}/album/${album.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
);
});
});
describe("defaultArtistArtURI", () => {
it("should create the correct URI", () => {
const webAddress = "http://localhost:1234";
const accessToken = uuid();
const artist = anArtist();
expect(defaultArtistArtURI(webAddress, accessToken, artist)).toEqual(
`${webAddress}/artist/${artist.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
);
});
});
describe("api", () => {
const rootUrl = "http://localhost:1234";
const service = bonobService("test-api", 133, rootUrl, "AppLink");
const musicService = {
generateToken: jest.fn(),
login: jest.fn(),
};
const linkCodes = {
mint: jest.fn(),
has: jest.fn(),
associate: jest.fn(),
associationFor: jest.fn(),
};
const musicLibrary = {
artists: jest.fn(),
artist: jest.fn(),
genres: jest.fn(),
playlists: jest.fn(),
playlist: jest.fn(),
albums: jest.fn(),
tracks: jest.fn(),
track: jest.fn(),
searchArtists: jest.fn(),
searchAlbums: jest.fn(),
searchTracks: jest.fn(),
};
const accessTokens = {
mint: jest.fn(),
authTokenFor: jest.fn(),
};
const clock = {
now: jest.fn(),
};
const server = makeServer(
SONOS_DISABLED,
service,
rootUrl,
(musicService as unknown) as MusicService,
(linkCodes as unknown) as LinkCodes,
(accessTokens as unknown) as AccessTokens,
clock
);
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
describe("pages", () => {
describe(LOGIN_ROUTE, () => {
describe("when the credentials are valid", () => {
it("should return 200 ok and have associated linkCode with user", async () => {
const username = "jane";
const password = "password100";
const linkCode = `linkCode-${uuid()}`;
const authToken = {
authToken: `authtoken-${uuid()}`,
userId: `${username}-uid`,
nickname: `${username}-nickname`,
};
linkCodes.has.mockReturnValue(true);
musicService.generateToken.mockResolvedValue(authToken);
linkCodes.associate.mockReturnValue(true);
const res = await request(server)
.post(LOGIN_ROUTE)
.type("form")
.send({ username, password, linkCode })
.expect(200);
expect(res.text).toContain("Login successful");
expect(musicService.generateToken).toHaveBeenCalledWith({
username,
password,
});
expect(linkCodes.has).toHaveBeenCalledWith(linkCode);
expect(linkCodes.associate).toHaveBeenCalledWith(linkCode, authToken);
});
});
describe("when credentials are invalid", () => {
it("should return 403 with message", async () => {
const username = "userDoesntExist";
const password = "password";
const linkCode = uuid();
const message = `Invalid user:${username}`;
linkCodes.has.mockReturnValue(true);
musicService.generateToken.mockResolvedValue({ message });
const res = await request(server)
.post(LOGIN_ROUTE)
.type("form")
.send({ username, password, linkCode })
.expect(403);
expect(res.text).toContain(`Login failed! ${message}`);
});
});
describe("when linkCode is invalid", () => {
it("should return 400 with message", async () => {
const username = "jane";
const password = "password100";
const linkCode = "someLinkCodeThatDoesntExist";
linkCodes.has.mockReturnValue(false);
const res = await request(server)
.post(LOGIN_ROUTE)
.type("form")
.send({ username, password, linkCode })
.expect(400);
expect(res.text).toContain("Invalid linkCode!");
});
});
});
});
describe("soap api", () => {
describe("getAppLink", () => {
it("should do something", async () => {
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
const linkCode = "theLinkCode8899";
linkCodes.mint.mockReturnValue(linkCode);
const result = await ws.getAppLinkAsync(getAppLinkMessage());
expect(result[0]).toEqual({
getAppLinkResult: {
authorizeAccount: {
appUrlStringId: "AppLinkMessage",
deviceLink: {
regUrl: `${rootUrl}/login?linkCode=${linkCode}`,
linkCode: linkCode,
showLinkCode: false,
},
},
},
});
});
});
describe("getDeviceAuthToken", () => {
describe("when there is a linkCode association", () => {
it("should return a device auth token", async () => {
const linkCode = uuid();
const association = {
authToken: "authToken",
userId: "uid",
nickname: "nick",
};
linkCodes.associationFor.mockReturnValue(association);
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
const result = await ws.getDeviceAuthTokenAsync({ linkCode });
expect(result[0]).toEqual({
getDeviceAuthTokenResult: {
authToken: association.authToken,
privateKey: "",
userInfo: {
nickname: association.nickname,
userIdHashCode: crypto
.createHash("sha256")
.update(association.userId)
.digest("hex"),
},
},
});
expect(linkCodes.associationFor).toHaveBeenCalledWith(linkCode);
});
});
describe("when there is no linkCode association", () => {
it("should return a device auth token", async () => {
const linkCode = "invalidLinkCode";
linkCodes.associationFor.mockReturnValue(undefined);
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
await ws
.getDeviceAuthTokenAsync({ linkCode })
.then(() => {
fail("Shouldnt get here");
})
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.NOT_LINKED_RETRY",
faultstring: "Link Code not found retry...",
detail: { ExceptionInfo: "NOT_LINKED_RETRY", SonosError: "5" },
});
});
});
});
});
describe("getLastUpdate", () => {
it("should return a result with some timestamps", async () => {
const now = dayjs();
clock.now.mockReturnValue(now);
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
const result = await ws.getLastUpdateAsync({});
expect(result[0]).toEqual({
getLastUpdateResult: {
favorites: `${now.unix()}`,
catalog: `${now.unix()}`,
pollInterval: 120,
},
});
});
});
describe("search", () => {
describe("when no credentials header provided", () => {
it("should return a fault of LoginUnsupported", async () => {
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
await ws
.getMetadataAsync({ id: "search", index: 0, count: 0 })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
});
});
});
});
describe("when invalid credentials are provided", () => {
it("should return a fault of LoginUnauthorized", async () => {
musicService.login.mockRejectedValue("fail!");
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials("someAuthToken") });
await ws
.getMetadataAsync({ id: "search", index: 0, count: 0 })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
});
});
});
});
describe("when valid credentials are provided", () => {
const authToken = `authToken-${uuid()}`;
const accessToken = `accessToken-${uuid()}`;
let ws: Client;
beforeEach(async () => {
musicService.login.mockResolvedValue(musicLibrary);
accessTokens.mint.mockReturnValue(accessToken);
ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials(authToken) });
});
describe("searching for albums", () => {
const album1 = anAlbum();
const album2 = anAlbum();
const albums = [album1, album2];
beforeEach(() => {
musicLibrary.searchAlbums.mockResolvedValue([
albumToAlbumSummary(album1),
albumToAlbumSummary(album2),
]);
});
it("should return the albums", async () => {
const term = "whoop";
const result = await ws.searchAsync({
id: "albums",
term,
});
expect(result[0]).toEqual(
searchResult({
mediaCollection: albums.map((it) =>
album(rootUrl, accessToken, albumToAlbumSummary(it))
),
index: 0,
total: 2,
})
);
expect(musicLibrary.searchAlbums).toHaveBeenCalledWith(term);
});
});
describe("searching for artists", () => {
const artist1 = anArtist();
const artist2 = anArtist();
const artists = [artist1, artist2];
beforeEach(() => {
musicLibrary.searchArtists.mockResolvedValue([
artistToArtistSummary(artist1),
artistToArtistSummary(artist2),
]);
});
it("should return the artists", async () => {
const term = "whoopie";
const result = await ws.searchAsync({
id: "artists",
term,
});
expect(result[0]).toEqual(
searchResult({
mediaCollection: artists.map((it) =>
artist(rootUrl, accessToken, artistToArtistSummary(it))
),
index: 0,
total: 2,
})
);
expect(musicLibrary.searchArtists).toHaveBeenCalledWith(term);
});
});
describe("searching for tracks", () => {
const track1 = aTrack();
const track2 = aTrack();
const tracks = [track1, track2];
beforeEach(() => {
musicLibrary.searchTracks.mockResolvedValue([track1, track2]);
});
it("should return the tracks", async () => {
const term = "whoopie";
const result = await ws.searchAsync({
id: "tracks",
term,
});
expect(result[0]).toEqual(
searchResult({
mediaCollection: tracks.map((it) =>
album(rootUrl, accessToken, it.album)
),
index: 0,
total: 2,
})
);
expect(musicLibrary.searchTracks).toHaveBeenCalledWith(term);
});
});
});
});
describe("getMetadata", () => {
describe("when no credentials header provided", () => {
it("should return a fault of LoginUnsupported", async () => {
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
await ws
.getMetadataAsync({ id: "root", index: 0, count: 0 })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
});
});
});
});
describe("when invalid credentials are provided", () => {
it("should return a fault of LoginUnauthorized", async () => {
musicService.login.mockRejectedValue("fail!");
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials("someAuthToken") });
await ws
.getMetadataAsync({ id: "root", index: 0, count: 0 })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
});
});
});
});
describe("when valid credentials are provided", () => {
const authToken = `authToken-${uuid()}`;
const accessToken = `accessToken-${uuid()}`;
let ws: Client;
beforeEach(async () => {
musicService.login.mockResolvedValue(musicLibrary);
accessTokens.mint.mockReturnValue(accessToken);
ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials(authToken) });
});
describe("asking for the root container", () => {
it("should return it", async () => {
const root = await ws.getMetadataAsync({
id: "root",
index: 0,
count: 100,
});
expect(root[0]).toEqual(
getMetadataResult({
mediaCollection: [
{ itemType: "container", id: "artists", title: "Artists" },
{ itemType: "albumList", id: "albums", title: "Albums" },
{
itemType: "container",
id: "playlists",
title: "Playlists",
},
{ itemType: "container", id: "genres", title: "Genres" },
{
itemType: "albumList",
id: "randomAlbums",
title: "Random",
},
{
itemType: "albumList",
id: "starredAlbums",
title: "Starred",
},
{
itemType: "albumList",
id: "recentlyAdded",
title: "Recently Added",
},
{
itemType: "albumList",
id: "recentlyPlayed",
title: "Recently Played",
},
{
itemType: "albumList",
id: "mostPlayed",
title: "Most Played",
},
],
index: 0,
total: 9,
})
);
});
});
describe("asking for the search container", () => {
it("should return it", async () => {
const root = await ws.getMetadataAsync({
id: "search",
index: 0,
count: 100,
});
expect(root[0]).toEqual(
getMetadataResult({
mediaCollection: [
{ itemType: "search", id: "artists", title: "Artists" },
{ itemType: "search", id: "albums", title: "Albums" },
{ itemType: "search", id: "tracks", title: "Tracks" },
],
index: 0,
total: 3,
})
);
});
});
describe("asking for a genres", () => {
const expectedGenres = [POP, PUNK, ROCK, TRIP_HOP];
beforeEach(() => {
musicLibrary.genres.mockResolvedValue(expectedGenres);
});
describe("asking for all genres", () => {
it("should return a collection of genres", async () => {
const result = await ws.getMetadataAsync({
id: `genres`,
index: 0,
count: 100,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: expectedGenres.map((genre) => ({
itemType: "container",
id: `genre:${genre.id}`,
title: genre.name,
})),
index: 0,
total: expectedGenres.length,
})
);
});
});
describe("asking for a page of genres", () => {
it("should return just that page", async () => {
const result = await ws.getMetadataAsync({
id: `genres`,
index: 1,
count: 2,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: [PUNK, ROCK].map((genre) => ({
itemType: "container",
id: `genre:${genre.id}`,
title: genre.name,
})),
index: 1,
total: expectedGenres.length,
})
);
});
});
});
describe("asking for playlists", () => {
const expectedPlayLists = [
{ id: "1", name: "pl1" },
{ id: "2", name: "pl2" },
{ id: "3", name: "pl3" },
{ id: "4", name: "pl4" },
];
beforeEach(() => {
musicLibrary.playlists.mockResolvedValue(expectedPlayLists);
});
describe("asking for all playlists", () => {
it("should return a collection of playlists", async () => {
const result = await ws.getMetadataAsync({
id: `playlists`,
index: 0,
count: 100,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: expectedPlayLists.map((playlist) => ({
itemType: "album",
id: `playlist:${playlist.id}`,
title: playlist.name,
canPlay: true,
})),
index: 0,
total: expectedPlayLists.length,
})
);
});
});
describe("asking for a page of playlists", () => {
it("should return just that page", async () => {
const result = await ws.getMetadataAsync({
id: `playlists`,
index: 1,
count: 2,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: [
expectedPlayLists[1]!,
expectedPlayLists[2]!,
].map((playlist) => ({
itemType: "album",
id: `playlist:${playlist.id}`,
title: playlist.name,
canPlay: true,
})),
index: 1,
total: expectedPlayLists.length,
})
);
});
});
});
describe("asking for a single artist", () => {
const artistWithManyAlbums = anArtist({
albums: [anAlbum(), anAlbum(), anAlbum(), anAlbum(), anAlbum()],
});
beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artistWithManyAlbums);
});
describe("asking for all albums", () => {
it("should return a collection of albums", async () => {
const result = await ws.getMetadataAsync({
id: `artist:${artistWithManyAlbums.id}`,
index: 0,
count: 100,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: artistWithManyAlbums.albums.map((it) => ({
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true,
artistId: it.artistId,
artist: it.artistName,
})),
index: 0,
total: artistWithManyAlbums.albums.length,
})
);
expect(musicLibrary.artist).toHaveBeenCalledWith(
artistWithManyAlbums.id
);
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
});
});
describe("asking for a page of albums", () => {
it("should return just that page", async () => {
const result = await ws.getMetadataAsync({
id: `artist:${artistWithManyAlbums.id}`,
index: 2,
count: 2,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: [
artistWithManyAlbums.albums[2]!,
artistWithManyAlbums.albums[3]!,
].map((it) => ({
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true,
artistId: it.artistId,
artist: it.artistName,
})),
index: 2,
total: artistWithManyAlbums.albums.length,
})
);
expect(musicLibrary.artist).toHaveBeenCalledWith(
artistWithManyAlbums.id
);
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
});
});
});
describe("asking for artists", () => {
const artistSummaries = [
anArtist(),
anArtist(),
anArtist(),
anArtist(),
anArtist(),
].map(artistToArtistSummary);
describe("asking for all artists", () => {
it("should return them all", async () => {
const index = 0;
const count = 100;
musicLibrary.artists.mockResolvedValue({
results: artistSummaries,
total: artistSummaries.length,
});
const result = await ws.getMetadataAsync({
id: "artists",
index,
count,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: artistSummaries.map((it) => ({
itemType: "artist",
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: defaultArtistArtURI(rootUrl, accessToken, it),
})),
index: 0,
total: artistSummaries.length,
})
);
expect(musicLibrary.artists).toHaveBeenCalledWith({
_index: index,
_count: count,
});
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
});
});
describe("asking for a page of artists", () => {
const index = 1;
const count = 3;
it("should return it", async () => {
const someArtists = [
artistSummaries[1]!,
artistSummaries[2]!,
artistSummaries[3]!,
];
musicLibrary.artists.mockResolvedValue({
results: someArtists,
total: artistSummaries.length,
});
const result = await ws.getMetadataAsync({
id: "artists",
index,
count,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: someArtists.map((it) => ({
itemType: "artist",
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: defaultArtistArtURI(rootUrl, accessToken, it),
})),
index: 1,
total: artistSummaries.length,
})
);
expect(musicLibrary.artists).toHaveBeenCalledWith({
_index: index,
_count: count,
});
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
});
});
});
describe("asking for relatedArtists", () => {
describe("when the artist has many", () => {
const relatedArtist1 = anArtist();
const relatedArtist2 = anArtist();
const relatedArtist3 = anArtist();
const relatedArtist4 = anArtist();
const artist = anArtist({
similarArtists: [
relatedArtist1,
relatedArtist2,
relatedArtist3,
relatedArtist4,
],
});
beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist);
});
describe("when they fit on one page", () => {
it("should return them", async () => {
const result = await ws.getMetadataAsync({
id: `relatedArtists:${artist.id}`,
index: 0,
count: 100,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: [
relatedArtist1,
relatedArtist2,
relatedArtist3,
relatedArtist4,
].map((it) => ({
itemType: "artist",
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: defaultArtistArtURI(
rootUrl,
accessToken,
it
),
})),
index: 0,
total: 4,
})
);
expect(musicLibrary.artist).toHaveBeenCalledWith(artist.id);
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
});
});
describe("when they dont fit on one page", () => {
it("should return them", async () => {
const result = await ws.getMetadataAsync({
id: `relatedArtists:${artist.id}`,
index: 1,
count: 2,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: [relatedArtist2, relatedArtist3].map(
(it) => ({
itemType: "artist",
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: defaultArtistArtURI(
rootUrl,
accessToken,
it
),
})
),
index: 1,
total: 4,
})
);
expect(musicLibrary.artist).toHaveBeenCalledWith(artist.id);
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
});
});
});
describe("when the artist has none", () => {
const artist = anArtist({ similarArtists: [] });
beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist);
});
it("should return an empty list", async () => {
const result = await ws.getMetadataAsync({
id: `relatedArtists:${artist.id}`,
index: 0,
count: 100,
});
expect(result[0]).toEqual(
getMetadataResult({
index: 0,
total: 0,
})
);
expect(musicLibrary.artist).toHaveBeenCalledWith(artist.id);
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
});
});
});
describe("asking for albums", () => {
const pop1 = anAlbum({ genre: POP });
const pop2 = anAlbum({ genre: POP });
const pop3 = anAlbum({ genre: POP });
const pop4 = anAlbum({ genre: POP });
const rock1 = anAlbum({ genre: ROCK });
const rock2 = anAlbum({ genre: ROCK });
const allAlbums = [pop1, pop2, pop3, pop4, rock1, rock2];
const popAlbums = [pop1, pop2, pop3, pop4];
describe("asking for random albums", () => {
const randomAlbums = [pop2, rock1, pop1];
beforeEach(() => {
musicLibrary.albums.mockResolvedValue({
results: randomAlbums,
total: allAlbums.length,
});
});
it("should return some", async () => {
const paging = {
index: 0,
count: 100,
};
const result = await ws.getMetadataAsync({
id: "randomAlbums",
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: randomAlbums.map((it) => ({
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true,
artistId: it.artistId,
artist: it.artistName,
})),
index: 0,
total: 6,
})
);
expect(musicLibrary.albums).toHaveBeenCalledWith({
type: "random",
_index: paging.index,
_count: paging.count,
});
});
});
describe("asking for starred albums", () => {
const albums = [rock2, rock1, pop2];
beforeEach(() => {
musicLibrary.albums.mockResolvedValue({
results: albums,
total: allAlbums.length,
});
});
it("should return some", async () => {
const paging = {
index: 0,
count: 100,
};
const result = await ws.getMetadataAsync({
id: "starredAlbums",
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: albums.map((it) => ({
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true,
artistId: it.artistId,
artist: it.artistName,
})),
index: 0,
total: 6,
})
);
expect(musicLibrary.albums).toHaveBeenCalledWith({
type: "starred",
_index: paging.index,
_count: paging.count,
});
});
});
describe("asking for recently played albums", () => {
const recentlyPlayed = [rock2, rock1, pop2];
beforeEach(() => {
musicLibrary.albums.mockResolvedValue({
results: recentlyPlayed,
total: allAlbums.length,
});
});
it("should return some", async () => {
const paging = {
index: 0,
count: 100,
};
const result = await ws.getMetadataAsync({
id: "recentlyPlayed",
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: recentlyPlayed.map((it) => ({
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true,
artistId: it.artistId,
artist: it.artistName,
})),
index: 0,
total: 6,
})
);
expect(musicLibrary.albums).toHaveBeenCalledWith({
type: "recent",
_index: paging.index,
_count: paging.count,
});
});
});
describe("asking for most played albums", () => {
const mostPlayed = [rock2, rock1, pop2];
beforeEach(() => {
musicLibrary.albums.mockResolvedValue({
results: mostPlayed,
total: allAlbums.length,
});
});
it("should return some", async () => {
const paging = {
index: 0,
count: 100,
};
const result = await ws.getMetadataAsync({
id: "mostPlayed",
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: mostPlayed.map((it) => ({
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true,
artistId: it.artistId,
artist: it.artistName,
})),
index: 0,
total: 6,
})
);
expect(musicLibrary.albums).toHaveBeenCalledWith({
type: "frequent",
_index: paging.index,
_count: paging.count,
});
});
});
describe("asking for recently added albums", () => {
const recentlyAdded = [pop4, pop3, pop2];
beforeEach(() => {
musicLibrary.albums.mockResolvedValue({
results: recentlyAdded,
total: allAlbums.length,
});
});
it("should return some", async () => {
const paging = {
index: 0,
count: 100,
};
const result = await ws.getMetadataAsync({
id: "recentlyAdded",
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: recentlyAdded.map((it) => ({
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true,
artistId: it.artistId,
artist: it.artistName,
})),
index: 0,
total: 6,
})
);
expect(musicLibrary.albums).toHaveBeenCalledWith({
type: "newest",
_index: paging.index,
_count: paging.count,
});
});
});
describe("asking for all albums", () => {
beforeEach(() => {
musicLibrary.albums.mockResolvedValue({
results: allAlbums,
total: allAlbums.length,
});
});
it("should return them all", async () => {
const paging = {
index: 0,
count: 100,
};
const result = await ws.getMetadataAsync({
id: "albums",
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: allAlbums.map((it) => ({
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true,
artistId: it.artistId,
artist: it.artistName,
})),
index: 0,
total: 6,
})
);
expect(musicLibrary.albums).toHaveBeenCalledWith({
type: "alphabeticalByArtist",
_index: paging.index,
_count: paging.count,
});
});
});
describe("asking for a page of albums", () => {
const pageOfAlbums = [pop3, pop4, rock1];
it("should return only that page", async () => {
const paging = {
index: 2,
count: 3,
};
musicLibrary.albums.mockResolvedValue({
results: pageOfAlbums,
total: allAlbums.length,
});
const result = await ws.getMetadataAsync({
id: "albums",
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: pageOfAlbums.map((it) => ({
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true,
artistId: it.artistId,
artist: it.artistName,
})),
index: 2,
total: 6,
})
);
expect(musicLibrary.albums).toHaveBeenCalledWith({
type: "alphabeticalByArtist",
_index: paging.index,
_count: paging.count,
});
});
});
describe("asking for all albums for a genre", () => {
it("should return albums for the genre", async () => {
const paging = {
index: 0,
count: 100,
};
musicLibrary.albums.mockResolvedValue({
results: popAlbums,
total: popAlbums.length,
});
const result = await ws.getMetadataAsync({
id: `genre:${POP.id}`,
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: [pop1, pop2, pop3, pop4].map((it) => ({
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true,
artistId: it.artistId,
artist: it.artistName,
})),
index: 0,
total: 4,
})
);
expect(musicLibrary.albums).toHaveBeenCalledWith({
type: "byGenre",
genre: POP.id,
_index: paging.index,
_count: paging.count,
});
});
});
describe("asking for a page of albums for a genre", () => {
const pageOfPop = [pop1, pop2];
it("should return albums for the genre", async () => {
const paging = {
index: 0,
count: 2,
};
musicLibrary.albums.mockResolvedValue({
results: pageOfPop,
total: popAlbums.length,
});
const result = await ws.getMetadataAsync({
id: `genre:${POP.id}`,
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: pageOfPop.map((it) => ({
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, it),
canPlay: true,
artistId: it.artistId,
artist: it.artistName,
})),
index: 0,
total: 4,
})
);
expect(musicLibrary.albums).toHaveBeenCalledWith({
type: "byGenre",
genre: POP.id,
_index: paging.index,
_count: paging.count,
});
});
});
});
describe("asking for an album", () => {
const album = anAlbum();
const artist = anArtist({
albums: [album],
});
const track1 = aTrack({ artist, album, number: 1 });
const track2 = aTrack({ artist, album, number: 2 });
const track3 = aTrack({ artist, album, number: 3 });
const track4 = aTrack({ artist, album, number: 4 });
const track5 = aTrack({ artist, album, number: 5 });
const tracks = [track1, track2, track3, track4, track5];
beforeEach(() => {
musicLibrary.tracks.mockResolvedValue(tracks);
});
describe("asking for all for an album", () => {
it("should return them all", async () => {
const paging = {
index: 0,
count: 100,
};
const result = await ws.getMetadataAsync({
id: `album:${album.id}`,
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaMetadata: tracks.map((it) =>
track(rootUrl, accessToken, it)
),
index: 0,
total: tracks.length,
})
);
expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id);
});
});
describe("asking for a single page of tracks", () => {
const pageOfTracks = [track3, track4];
it("should return only that page", async () => {
const paging = {
index: 2,
count: 2,
};
const result = await ws.getMetadataAsync({
id: `album:${album.id}`,
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaMetadata: pageOfTracks.map((it) =>
track(rootUrl, accessToken, it)
),
index: paging.index,
total: tracks.length,
})
);
expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id);
});
});
});
describe("asking for a playlist", () => {
const track1 = aTrack();
const track2 = aTrack();
const track3 = aTrack();
const track4 = aTrack();
const track5 = aTrack();
const playlist = {
id: uuid(),
name: "playlist for test",
entries: [track1, track2, track3, track4, track5]
}
beforeEach(() => {
musicLibrary.playlist.mockResolvedValue(playlist);
});
describe("asking for all for a playlist", () => {
it("should return them all", async () => {
const paging = {
index: 0,
count: 100,
};
const result = await ws.getMetadataAsync({
id: `playlist:${playlist.id}`,
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaMetadata: playlist.entries.map((it) =>
track(rootUrl, accessToken, it)
),
index: 0,
total: playlist.entries.length,
})
);
expect(musicLibrary.playlist).toHaveBeenCalledWith(playlist.id);
});
});
describe("asking for a single page of a playlists entries", () => {
const pageOfTracks = [track3, track4];
it("should return only that page", async () => {
const paging = {
index: 2,
count: 2,
};
const result = await ws.getMetadataAsync({
id: `playlist:${playlist.id}`,
...paging,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaMetadata: pageOfTracks.map((it) =>
track(rootUrl, accessToken, it)
),
index: paging.index,
total: playlist.entries.length,
})
);
expect(musicLibrary.playlist).toHaveBeenCalledWith(playlist.id);
});
});
});
});
});
describe("getExtendedMetadata", () => {
describe("when no credentials header provided", () => {
it("should return a fault of LoginUnsupported", async () => {
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
await ws
.getExtendedMetadataAsync({ id: "root", index: 0, count: 0 })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
});
});
});
});
describe("when invalid credentials are provided", () => {
it("should return a fault of LoginUnauthorized", async () => {
musicService.login.mockRejectedValue("booom!");
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials("someAuthToken") });
await ws
.getExtendedMetadataAsync({ id: "root", index: 0, count: 0 })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
});
});
});
});
describe("when valid credentials are provided", () => {
let ws: Client;
const authToken = `authToken-${uuid()}`;
const accessToken = `accessToken-${uuid()}`;
beforeEach(async () => {
musicService.login.mockResolvedValue(musicLibrary);
accessTokens.mint.mockReturnValue(accessToken);
ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials(authToken) });
});
describe("asking for an artist", () => {
describe("when it has some albums", () => {
const album1 = anAlbum();
const album2 = anAlbum();
const album3 = anAlbum();
const artist = anArtist({
similarArtists: [],
albums: [album1, album2, album3],
});
beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist);
});
describe("when all albums fit on a page", () => {
it("should return the albums", async () => {
const paging = {
index: 0,
count: 100,
};
const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`,
...paging,
});
expect(root[0]).toEqual({
getExtendedMetadataResult: {
count: "3",
index: "0",
total: "3",
mediaCollection: artist.albums.map((it) =>
album(rootUrl, accessToken, it)
),
},
});
});
});
describe("getting a page of albums", () => {
it("should return only that page", async () => {
const paging = {
index: 1,
count: 2,
};
const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`,
...paging,
});
expect(root[0]).toEqual({
getExtendedMetadataResult: {
count: "2",
index: "1",
total: "3",
mediaCollection: [album2, album3].map((it) =>
album(rootUrl, accessToken, it)
),
},
});
});
});
});
describe("when it has similar artists", () => {
const similar1 = anArtist();
const similar2 = anArtist();
const artist = anArtist({
similarArtists: [similar1, similar2],
albums: [],
});
beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist);
});
it("should return a RELATED_ARTISTS browse option", async () => {
const paging = {
index: 0,
count: 100,
};
const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`,
...paging,
});
expect(root[0]).toEqual({
getExtendedMetadataResult: {
// artist has no albums
count: "0",
index: "0",
total: "0",
relatedBrowse: [
{
id: `relatedArtists:${artist.id}`,
type: "RELATED_ARTISTS",
},
],
},
});
});
});
describe("when it has no similar artists", () => {
const artist = anArtist({
similarArtists: [],
albums: [],
});
beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist);
});
it("should not return a RELATED_ARTISTS browse option", async () => {
const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`,
index: 0,
count: 100,
});
expect(root[0]).toEqual({
getExtendedMetadataResult: {
// artist has no albums
count: "0",
index: "0",
total: "0",
},
});
});
});
});
});
});
describe("getMediaURI", () => {
describe("when no credentials header provided", () => {
it("should return a fault of LoginUnsupported", async () => {
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
await ws
.getMediaURIAsync({ id: "track:123" })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
});
});
});
});
describe("when invalid credentials are provided", () => {
it("should return a fault of LoginUnauthorized", async () => {
musicService.login.mockRejectedValue("Credentials not found");
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials("invalid token") });
await ws
.getMediaURIAsync({ id: "track:123" })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
});
});
});
});
describe("when valid credentials are provided", () => {
const authToken = `authToken-${uuid()}`;
let ws: Client;
const accessToken = `temporaryAccessToken-${uuid()}`;
beforeEach(async () => {
musicService.login.mockResolvedValue(musicLibrary);
accessTokens.mint.mockReturnValue(accessToken);
ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials(authToken) });
});
describe("asking for a URI to stream a track", () => {
it("should return it with auth header", async () => {
const trackId = uuid();
const root = await ws.getMediaURIAsync({
id: `track:${trackId}`,
});
expect(root[0]).toEqual({
getMediaURIResult: `${rootUrl}/stream/track/${trackId}`,
httpHeaders: {
header: BONOB_ACCESS_TOKEN_HEADER,
value: accessToken,
},
});
expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
});
});
});
});
describe("getMediaMetadata", () => {
describe("when no credentials header provided", () => {
it("should return a fault of LoginUnsupported", async () => {
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
await ws
.getMediaMetadataAsync({ id: "track:123" })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnsupported",
faultstring: "Missing credentials...",
});
});
});
});
describe("when invalid credentials are provided", () => {
it("should return a fault of LoginUnauthorized", async () => {
musicService.login.mockRejectedValue("Credentials not found!!");
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({
credentials: someCredentials("some invalid token"),
});
await ws
.getMediaMetadataAsync({ id: "track:123" })
.then(() => fail("shouldnt get here"))
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.LoginUnauthorized",
faultstring: "Credentials not found...",
});
});
});
});
describe("when valid credentials are provided", () => {
const authToken = `authToken-${uuid()}`;
const accessToken = `accessToken-${uuid()}`;
let ws: Client;
const someTrack = aTrack();
beforeEach(async () => {
musicService.login.mockResolvedValue(musicLibrary);
accessTokens.mint.mockReturnValue(accessToken);
musicLibrary.track.mockResolvedValue(someTrack);
ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials(authToken) });
});
describe("asking for media metadata for a track", () => {
it("should return it with auth header", async () => {
const root = await ws.getMediaMetadataAsync({
id: `track:${someTrack.id}`,
});
expect(root[0]).toEqual({
getMediaMetadataResult: track(rootUrl, accessToken, someTrack),
});
expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
expect(musicLibrary.track).toHaveBeenCalledWith(someTrack.id);
});
});
});
});
});
});