From 1153f8e318ba2f99ab2e4dd0921e5fd2a13baf86 Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 23 Jul 2021 18:11:32 +1000 Subject: [PATCH] Add support for running under a context path, ie. /bonob, replace BONOB_WEB_ADDRESS with BONOB_URL --- README.md | 8 +- src/app.ts | 17 +- src/config.ts | 19 +- src/register.ts | 2 +- src/server.ts | 23 +- src/smapi.ts | 100 +- src/sonos.ts | 15 +- src/url_builder.ts | 72 + tests/config.test.ts | 102 +- tests/scenarios.test.ts | 226 +- tests/server.test.ts | 1512 +++++++------ tests/smapi.test.ts | 4402 +++++++++++++++++++------------------ tests/sonos.test.ts | 36 +- tests/supersoap.ts | 5 +- tests/url_builder.test.ts | 212 ++ web/views/index.eta | 2 +- 16 files changed, 3700 insertions(+), 3053 deletions(-) create mode 100644 src/url_builder.ts create mode 100644 tests/url_builder.test.ts diff --git a/README.md b/README.md index d49782b..b13492a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Start bonob outside the lan with sonos discovery & registration disabled as they ```bash docker run \ -e BONOB_PORT=4534 \ - -e BONOB_WEB_ADDRESS=https://my-bonob-service.com \ + -e BONOB_URL=https://my-server.example.com/bonob \ -e BONOB_SONOS_AUTO_REGISTER=false \ -e BONOB_SONOS_DEVICE_DISCOVERY=false \ -e BONOB_NAVIDROME_URL=https://my-navidrome-service.com:4533 \ @@ -71,11 +71,11 @@ docker run \ simojenki/bonob ``` -Now inside the lan that contains the sonos devices run bonob registration, using the same BONOB_WEB_ADDRESS as above, and with discovery enabled. Make sure to use host networking so that bonob can find the sonos devices (or provide a BONOB_SONOS_SEED_HOST) +Now inside the lan that contains the sonos devices run bonob registration, using the same BONOB_URL as above, and with discovery enabled. Make sure to use host networking so that bonob can find the sonos devices (or provide a BONOB_SONOS_SEED_HOST) ```bash docker run \ - -e BONOB_WEB_ADDRESS=https://my-bonob-service.com \ + -e BONOB_URL=https://my-server.example.com/bonob \ -e BONOB_SONOS_DEVICE_DISCOVERY=true \ --network host \ simojenki/bonob register @@ -86,7 +86,7 @@ docker run \ item | default value | description ---- | ------------- | ----------- BONOB_PORT | 4534 | Default http port for bonob to listen on -BONOB_WEB_ADDRESS | http://$(hostname):4534 | URL for bonob so that sonos devices can communicate. **This must be either the public IP or DNS entry of the bonob instance so that the sonos devices can communicate with it.** +BONOB_URL | http://$(hostname):4534 | URL (including path) for bonob so that sonos devices can communicate. **This must be either the public IP or DNS entry of the bonob instance so that the sonos devices can communicate with it.** BONOB_SECRET | bonob | secret used for encrypting credentials BONOB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register on startup BONOB_SONOS_DEVICE_DISCOVERY | true | whether or not sonos device discovery should be enabled diff --git a/src/app.ts b/src/app.ts index b08c55c..a2f1140 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,6 +7,7 @@ import { InMemoryLinkCodes } from "./link_codes"; import readConfig from "./config"; import sonos, { bonobService } from "./sonos"; import { MusicService } from "./music_service"; +import { SystemClock } from "./clock"; const config = readConfig(); @@ -15,7 +16,7 @@ logger.info(`Starting bonob with config ${JSON.stringify(config)}`); const bonob = bonobService( config.sonos.serviceName, config.sonos.sid, - config.webAddress, + config.bonobUrl, "AppLink" ); @@ -40,15 +41,15 @@ const featureFlagAwareMusicService: MusicService = { scrobble: (id: string) => { if (config.scrobbleTracks) return library.scrobble(id); else { - logger.info("Track Scrobbling not enabled") - return Promise.resolve(false); + logger.info("Track Scrobbling not enabled"); + return Promise.resolve(true); } }, nowPlaying: (id: string) => { if (config.reportNowPlaying) return library.nowPlaying(id); else { logger.info("Reporting track now playing not enabled"); - return Promise.resolve(false); + return Promise.resolve(true); } }, }; @@ -58,10 +59,12 @@ const featureFlagAwareMusicService: MusicService = { const app = server( sonosSystem, bonob, - config.webAddress, + config.bonobUrl, featureFlagAwareMusicService, new InMemoryLinkCodes(), - new InMemoryAccessTokens(sha256(config.secret)) + new InMemoryAccessTokens(sha256(config.secret)), + SystemClock, + true, ); if (config.sonos.autoRegister) { @@ -75,7 +78,7 @@ if (config.sonos.autoRegister) { } app.listen(config.port, () => { - logger.info(`Listening on ${config.port} available @ ${config.webAddress}`); + logger.info(`Listening on ${config.port} available @ ${config.bonobUrl}`); }); export default app; diff --git a/src/config.ts b/src/config.ts index 3d40769..ff1c560 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,28 +1,32 @@ import { hostname } from "os"; import logger from "./logger"; +import url from "./url_builder"; export default function () { const port = +(process.env["BONOB_PORT"] || 4534); - const webAddress = - process.env["BONOB_WEB_ADDRESS"] || `http://${hostname()}:${port}`; + const bonobUrl = + process.env["BONOB_URL"] || + process.env["BONOB_WEB_ADDRESS"] || + `http://${hostname()}:${port}`; - if (webAddress.match("localhost")) { + if (bonobUrl.match("localhost")) { logger.error( - "BONOB_WEB_ADDRESS containing localhost is almost certainly incorrect, sonos devices will not be able to communicate with bonob using localhost, please specify either public IP or DNS entry" + "BONOB_URL containing localhost is almost certainly incorrect, sonos devices will not be able to communicate with bonob using localhost, please specify either public IP or DNS entry" ); process.exit(1); } return { port, - webAddress, + bonobUrl: url(bonobUrl), secret: process.env["BONOB_SECRET"] || "bonob", sonos: { serviceName: process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob", deviceDiscovery: (process.env["BONOB_SONOS_DEVICE_DISCOVERY"] || "true") == "true", seedHost: process.env["BONOB_SONOS_SEED_HOST"], - autoRegister: (process.env["BONOB_SONOS_AUTO_REGISTER"] || "false") == "true", + autoRegister: + (process.env["BONOB_SONOS_AUTO_REGISTER"] || "false") == "true", sid: Number(process.env["BONOB_SONOS_SERVICE_ID"] || "246"), }, navidrome: { @@ -31,6 +35,7 @@ export default function () { process.env["BONOB_NAVIDROME_CUSTOM_CLIENTS"] || undefined, }, scrobbleTracks: (process.env["BONOB_SCROBBLE_TRACKS"] || "true") == "true", - reportNowPlaying: (process.env["BONOB_REPORT_NOW_PLAYING"] || "true") == "true", + reportNowPlaying: + (process.env["BONOB_REPORT_NOW_PLAYING"] || "true") == "true", }; } diff --git a/src/register.ts b/src/register.ts index 2a40cb7..97b815f 100644 --- a/src/register.ts +++ b/src/register.ts @@ -7,7 +7,7 @@ const config = readConfig(); const bonob = bonobService( config.sonos.serviceName, config.sonos.sid, - config.webAddress, + config.bonobUrl, "AppLink" ); diff --git a/src/server.ts b/src/server.ts index 9bd1ff7..d2bbfe7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,7 @@ import { PRESENTATION_MAP_ROUTE, SONOS_RECOMMENDED_IMAGE_SIZES, LOGIN_ROUTE, + REGISTER_ROUTE, } from "./smapi"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { MusicService, isSuccess } from "./music_service"; @@ -20,6 +21,7 @@ import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens"; import logger from "./logger"; import { Clock, SystemClock } from "./clock"; import { pipe } from "fp-ts/lib/function"; +import { URLBuilder } from "./url_builder"; export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token"; @@ -63,17 +65,19 @@ export class RangeBytesFromFilter extends Transform { function server( sonos: Sonos, service: Service, - webAddress: string, + bonobUrl: URLBuilder, musicService: MusicService, linkCodes: LinkCodes = new InMemoryLinkCodes(), accessTokens: AccessTokens = new AccessTokenPerAuthToken(), - clock: Clock = SystemClock + clock: Clock = SystemClock, + applyContextPath = true ): Express { const app = express(); app.use(morgan("combined")); app.use(express.urlencoded({ extended: false })); + // todo: pass options in here? app.use(express.static("./web/public")); app.engine("eta", Eta.renderFile); @@ -91,12 +95,13 @@ function server( services, bonobService: service, registeredBonobService, + registerRoute: bonobUrl.append({ pathname: REGISTER_ROUTE }).pathname(), }); } ); }); - app.post("/register", (_, res) => { + app.post(REGISTER_ROUTE, (_, res) => { sonos.register(service).then((success) => { if (success) { res.render("success", { @@ -114,7 +119,7 @@ function server( res.render("login", { bonobService: service, linkCode: req.query.linkCode, - loginRoute: LOGIN_ROUTE, + loginRoute: bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname(), }); }); @@ -317,14 +322,20 @@ function server( bindSmapiSoapServiceToExpress( app, SOAP_PATH, - webAddress, + bonobUrl, linkCodes, musicService, accessTokens, clock ); - return app; + if (applyContextPath) { + const container = express(); + container.use(bonobUrl.path(), app); + return container; + } else { + return app; + } } export default server; diff --git a/src/smapi.ts b/src/smapi.ts index 94bc33e..ec73809 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -20,8 +20,10 @@ import { import { AccessTokens } from "./access_tokens"; import { BONOB_ACCESS_TOKEN_HEADER } from "./server"; import { Clock } from "./clock"; +import { URLBuilder } from "./url_builder"; export const LOGIN_ROUTE = "/login"; +export const REGISTER_ROUTE = "/register"; export const SOAP_PATH = "/ws/sonos"; export const STRINGS_ROUTE = "/sonos/strings.xml"; export const PRESENTATION_MAP_ROUTE = "/sonos/presentationMap.xml"; @@ -131,10 +133,10 @@ export function searchResult( class SonosSoap { linkCodes: LinkCodes; - webAddress: string; + bonobUrl: URLBuilder; - constructor(webAddress: string, linkCodes: LinkCodes) { - this.webAddress = webAddress; + constructor(bonobUrl: URLBuilder, linkCodes: LinkCodes) { + this.bonobUrl = bonobUrl; this.linkCodes = linkCodes; } @@ -145,7 +147,10 @@ class SonosSoap { authorizeAccount: { appUrlStringId: "AppLinkMessage", deviceLink: { - regUrl: `${this.webAddress}${LOGIN_ROUTE}?linkCode=${linkCode}`, + regUrl: this.bonobUrl + .append({ pathname: LOGIN_ROUTE }) + .with({ searchParams: { linkCode } }) + .href(), linkCode: linkCode, showLinkCode: false, }, @@ -216,31 +221,21 @@ const playlist = (playlist: PlaylistSummary) => ({ }, }); -export const defaultAlbumArtURI = ( - webAddress: string, - accessToken: string, - album: AlbumSummary -) => - `${webAddress}/album/${album.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`; +export const defaultAlbumArtURI = (bonobUrl: URLBuilder, album: AlbumSummary) => + bonobUrl.append({ pathname: `/album/${album.id}/art/size/180` }); export const defaultArtistArtURI = ( - webAddress: string, - accessToken: string, + bonobUrl: URLBuilder, artist: ArtistSummary -) => - `${webAddress}/artist/${artist.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`; +) => bonobUrl.append({ pathname: `/artist/${artist.id}/art/size/180` }); -export const album = ( - webAddress: string, - accessToken: string, - album: AlbumSummary -) => ({ +export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({ itemType: "album", id: `album:${album.id}`, artist: album.artistName, artistId: album.artistId, title: album.name, - albumArtURI: defaultAlbumArtURI(webAddress, accessToken, album), + albumArtURI: defaultAlbumArtURI(bonobUrl, album).href(), canPlay: true, // defaults // canScroll: false, @@ -248,11 +243,7 @@ export const album = ( // canAddToFavorites: true }); -export const track = ( - webAddress: string, - accessToken: string, - track: Track -) => ({ +export const track = (bonobUrl: URLBuilder, track: Track) => ({ itemType: "track", id: `track:${track.id}`, mimeType: track.mimeType, @@ -263,7 +254,7 @@ export const track = ( albumId: track.album.id, albumArtist: track.artist.name, albumArtistId: track.artist.id, - albumArtURI: defaultAlbumArtURI(webAddress, accessToken, track.album), + albumArtURI: defaultAlbumArtURI(bonobUrl, track.album).href(), artist: track.artist.name, artistId: track.artist.id, duration: track.duration, @@ -273,16 +264,12 @@ export const track = ( }, }); -export const artist = ( - webAddress: string, - accessToken: string, - artist: ArtistSummary -) => ({ +export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({ itemType: "artist", id: `artist:${artist.id}`, artistId: artist.id, title: artist.name, - albumArtURI: defaultArtistArtURI(webAddress, accessToken, artist), + albumArtURI: defaultArtistArtURI(bonobUrl, artist).href(), }); const auth = async ( @@ -334,13 +321,20 @@ type SoapyHeaders = { function bindSmapiSoapServiceToExpress( app: Express, soapPath: string, - webAddress: string, + bonobUrl: URLBuilder, linkCodes: LinkCodes, musicService: MusicService, accessTokens: AccessTokens, clock: Clock ) { - const sonosSoap = new SonosSoap(webAddress, linkCodes); + const sonosSoap = new SonosSoap(bonobUrl, linkCodes); + const urlWithToken = (accessToken: string) => + bonobUrl.append({ + searchParams: { + "bonob-access-token": accessToken, + }, + }); + const soapyService = listen( app, soapPath, @@ -366,7 +360,11 @@ function bindSmapiSoapServiceToExpress( auth(musicService, accessTokens, headers) .then(splitId(id)) .then(({ accessToken, type, typeId }) => ({ - getMediaURIResult: `${webAddress}/stream/${type}/${typeId}`, + getMediaURIResult: bonobUrl + .append({ + pathname: `/stream/${type}/${typeId}`, + }) + .href(), httpHeaders: [ { header: BONOB_ACCESS_TOKEN_HEADER, @@ -383,7 +381,10 @@ function bindSmapiSoapServiceToExpress( .then(splitId(id)) .then(async ({ musicLibrary, accessToken, typeId }) => musicLibrary.track(typeId!).then((it) => ({ - getMediaMetadataResult: track(webAddress, accessToken, it), + getMediaMetadataResult: track( + urlWithToken(accessToken), + it + ), })) ), search: async ( @@ -400,7 +401,7 @@ function bindSmapiSoapServiceToExpress( searchResult({ count: it.length, mediaCollection: it.map((albumSummary) => - album(webAddress, accessToken, albumSummary) + album(urlWithToken(accessToken), albumSummary) ), }) ); @@ -409,7 +410,7 @@ function bindSmapiSoapServiceToExpress( searchResult({ count: it.length, mediaCollection: it.map((artistSummary) => - artist(webAddress, accessToken, artistSummary) + artist(urlWithToken(accessToken), artistSummary) ), }) ); @@ -418,7 +419,7 @@ function bindSmapiSoapServiceToExpress( searchResult({ count: it.length, mediaCollection: it.map((aTrack) => - album(webAddress, accessToken, aTrack.album) + album(urlWithToken(accessToken), aTrack.album) ), }) ); @@ -452,7 +453,7 @@ function bindSmapiSoapServiceToExpress( index: paging._index, total, mediaCollection: page.map((it) => - album(webAddress, accessToken, it) + album(urlWithToken(accessToken), it) ), relatedBrowse: artist.similarArtists.length > 0 @@ -483,10 +484,9 @@ function bindSmapiSoapServiceToExpress( genreId: it.genre?.id, duration: it.duration, albumArtURI: defaultAlbumArtURI( - webAddress, - accessToken, + urlWithToken(accessToken), it.album - ), + ).href(), }, }, }, @@ -500,7 +500,7 @@ function bindSmapiSoapServiceToExpress( userContent: false, renameable: false, }, - ...album(webAddress, accessToken, it), + ...album(urlWithToken(accessToken), it), }, // // @@ -537,7 +537,7 @@ function bindSmapiSoapServiceToExpress( musicLibrary.albums(q).then((result) => { return getMetadataResult({ mediaCollection: result.results.map((it) => - album(webAddress, accessToken, it) + album(urlWithToken(accessToken), it) ), index: paging._index, total: result.total, @@ -616,7 +616,7 @@ function bindSmapiSoapServiceToExpress( return musicLibrary.artists(paging).then((result) => { return getMetadataResult({ mediaCollection: result.results.map((it) => - artist(webAddress, accessToken, it) + artist(urlWithToken(accessToken), it) ), index: paging._index, total: result.total, @@ -689,7 +689,7 @@ function bindSmapiSoapServiceToExpress( .then(([page, total]) => { return getMetadataResult({ mediaMetadata: page.map((it) => - track(webAddress, accessToken, it) + track(urlWithToken(accessToken), it) ), index: paging._index, total, @@ -703,7 +703,7 @@ function bindSmapiSoapServiceToExpress( .then(([page, total]) => { return getMetadataResult({ mediaCollection: page.map((it) => - album(webAddress, accessToken, it) + album(urlWithToken(accessToken), it) ), index: paging._index, total, @@ -717,7 +717,7 @@ function bindSmapiSoapServiceToExpress( .then(([page, total]) => { return getMetadataResult({ mediaCollection: page.map((it) => - artist(webAddress, accessToken, it) + artist(urlWithToken(accessToken), it) ), index: paging._index, total, @@ -730,7 +730,7 @@ function bindSmapiSoapServiceToExpress( .then(([page, total]) => { return getMetadataResult({ mediaMetadata: page.map((it) => - track(webAddress, accessToken, it) + track(urlWithToken(accessToken), it) ), index: paging._index, total, diff --git a/src/sonos.ts b/src/sonos.ts index c383aa9..92c0e01 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -6,6 +6,7 @@ import { head } from "underscore"; import logger from "./logger"; import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from "./smapi"; import qs from "querystring"; +import { URLBuilder } from "./url_builder"; export const PRESENTATION_AND_STRINGS_VERSION = "18"; @@ -49,25 +50,25 @@ export type Service = { authType: "Anonymous" | "AppLink" | "DeviceLink" | "UserId"; }; -export const stripTailingSlash = (url: string) => +export const stripTrailingSlash = (url: string) => url.endsWith("/") ? url.substring(0, url.length - 1) : url; export const bonobService = ( name: string, sid: number, - bonobRoot: string, + bonobUrl: URLBuilder, authType: "Anonymous" | "AppLink" | "DeviceLink" | "UserId" = "AppLink" ): Service => ({ name, sid, - uri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`, - secureUri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`, + uri: bonobUrl.append({pathname: SOAP_PATH }).href(), + secureUri: bonobUrl.append({pathname: SOAP_PATH }).href(), strings: { - uri: `${stripTailingSlash(bonobRoot)}${STRINGS_ROUTE}`, + uri: bonobUrl.append({pathname: STRINGS_ROUTE }).href(), version: PRESENTATION_AND_STRINGS_VERSION, }, presentation: { - uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_ROUTE}`, + uri: bonobUrl.append({pathname: PRESENTATION_MAP_ROUTE }).href(), version: PRESENTATION_AND_STRINGS_VERSION, }, pollInterval: 1200, @@ -83,7 +84,7 @@ export interface Sonos { export const SONOS_DISABLED: Sonos = { devices: () => Promise.resolve([]), services: () => Promise.resolve([]), - register: (_: Service) => Promise.resolve(false), + register: (_: Service) => Promise.resolve(true), }; export const asService = (musicService: MusicService): Service => ({ diff --git a/src/url_builder.ts b/src/url_builder.ts new file mode 100644 index 0000000..7a514c0 --- /dev/null +++ b/src/url_builder.ts @@ -0,0 +1,72 @@ +function isURL(url: string | URL): url is URL { + return (url as URL).href !== undefined; +} + +function isURLSearchParams( + searchParams: Record | URLSearchParams +): searchParams is URLSearchParams { + return (searchParams as URLSearchParams).getAll !== undefined; +} + +const stripTrailingSlash = (url: string) => + url.endsWith("/") ? url.substring(0, url.length - 1) : url; + +export class URLBuilder { + private url: URL; + + constructor(url: string | URL) { + this.url = isURL(url) ? url : new URL(url); + } + + public append = ( + bits: Partial<{ + pathname: string | undefined; + searchParams: Record | URLSearchParams; + }> = { pathname: undefined, searchParams: undefined } + ) => { + let result = new URLBuilder(this.url); + if (bits.pathname) + result = result.with({ + pathname: stripTrailingSlash(this.url.pathname) + bits.pathname, + }); + if (bits.searchParams) { + const newSearchParams = new URLSearchParams(this.url.searchParams); + (isURLSearchParams(bits.searchParams) + ? bits.searchParams + : new URLSearchParams(bits.searchParams) + ).forEach((v, k) => newSearchParams.append(k, v)); + result = result.with({ searchParams: newSearchParams }); + } + return result; + }; + + public with = ( + bits: Partial<{ + pathname: string | undefined; + searchParams: Record | URLSearchParams; + }> = { pathname: undefined, searchParams: undefined } + ) => { + const result = new URL(this.url.href); + if (bits.pathname) result.pathname = bits.pathname; + if (bits.searchParams) { + const keysToDelete: string[] = []; + result.searchParams.forEach((_, k) => keysToDelete.push(k)); + keysToDelete.forEach((k) => result.searchParams.delete(k)); + (isURLSearchParams(bits.searchParams) + ? bits.searchParams + : new URLSearchParams(bits.searchParams) + ).forEach((v, k) => result.searchParams.append(k, v)); + } + return new URLBuilder(result); + }; + + public href = () => this.url.href; + public pathname = () => this.url.pathname; + public searchParams = () => this.url.searchParams; + public path = () => this.url.pathname + this.url.search; + public toString = () => this.url.href; +} + +export default function url(url: string | URL): URLBuilder { + return new URLBuilder(url); +} diff --git a/tests/config.test.ts b/tests/config.test.ts index dd8cfb5..755909d 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -13,7 +13,12 @@ describe("config", () => { process.env = OLD_ENV; }); - function describeBooleanConfigValue(name: string, envVar: string, expectedDefault: boolean, propertyGetter: (config: any) => any) { + function describeBooleanConfigValue( + name: string, + envVar: string, + expectedDefault: boolean, + propertyGetter: (config: any) => any + ) { describe(name, () => { function expecting({ value, @@ -35,7 +40,72 @@ describe("config", () => { expecting({ value: "false", expected: false }); expecting({ value: "foo", expected: false }); }); - }; + } + + describe("bonobUrl", () => { + describe("when BONOB_URL is specified", () => { + it("should be used", () => { + const url = "http://bonob1.example.com:8877/"; + process.env["BONOB_URL"] = url; + + expect(config().bonobUrl.href()).toEqual(url); + }); + }); + + describe("when BONOB_URL is not specified, however legacy BONOB_WEB_ADDRESS is specified", () => { + it("should be used", () => { + const url = "http://bonob2.example.com:9988/"; + process.env["BONOB_URL"] = ""; + process.env["BONOB_WEB_ADDRESS"] = url; + + expect(config().bonobUrl.href()).toEqual(url); + }); + }); + + describe("when neither BONOB_URL nor BONOB_WEB_ADDRESS are specified", () => { + describe("when BONOB_PORT is not specified", () => { + it(`should default to http://${hostname()}:4534`, () => { + expect(config().bonobUrl.href()).toEqual( + `http://${hostname()}:4534/` + ); + }); + }); + + describe("when BONOB_PORT is specified as 3322", () => { + it(`should default to http://${hostname()}:3322`, () => { + process.env["BONOB_PORT"] = "3322"; + expect(config().bonobUrl.href()).toEqual( + `http://${hostname()}:3322/` + ); + }); + }); + }); + }); + + describe("navidrome", () => { + describe("url", () => { + describe("when BONOB_NAVIDROME_URL is not specified", () => { + it(`should default to http://${hostname()}:4533`, () => { + expect(config().navidrome.url).toEqual(`http://${hostname()}:4533`); + }); + }); + + describe("when BONOB_NAVIDROME_URL is ''", () => { + it(`should default to http://${hostname()}:4533`, () => { + process.env["BONOB_NAVIDROME_URL"] = ""; + expect(config().navidrome.url).toEqual(`http://${hostname()}:4533`); + }); + }); + + describe("when BONOB_NAVIDROME_URL is specified", () => { + it(`should use it`, () => { + const url = "http://navidrome.example.com:1234"; + process.env["BONOB_NAVIDROME_URL"] = url; + expect(config().navidrome.url).toEqual(url); + }); + }); + }); + }); describe("secret", () => { it("should default to bonob", () => { @@ -60,7 +130,12 @@ describe("config", () => { }); }); - describeBooleanConfigValue("deviceDiscovery", "BONOB_SONOS_DEVICE_DISCOVERY", true, config => config.sonos.deviceDiscovery); + describeBooleanConfigValue( + "deviceDiscovery", + "BONOB_SONOS_DEVICE_DISCOVERY", + true, + (config) => config.sonos.deviceDiscovery + ); describe("seedHost", () => { it("should default to undefined", () => { @@ -73,7 +148,12 @@ describe("config", () => { }); }); - describeBooleanConfigValue("autoRegister", "BONOB_SONOS_AUTO_REGISTER", false, config => config.sonos.autoRegister); + describeBooleanConfigValue( + "autoRegister", + "BONOB_SONOS_AUTO_REGISTER", + false, + (config) => config.sonos.autoRegister + ); describe("sid", () => { it("should default to 246", () => { @@ -111,6 +191,16 @@ describe("config", () => { }); }); - describeBooleanConfigValue("scrobbleTracks", "BONOB_SCROBBLE_TRACKS", true, config => config.scrobbleTracks); - describeBooleanConfigValue("reportNowPlaying", "BONOB_REPORT_NOW_PLAYING", true, config => config.reportNowPlaying); + describeBooleanConfigValue( + "scrobbleTracks", + "BONOB_SCROBBLE_TRACKS", + true, + (config) => config.scrobbleTracks + ); + describeBooleanConfigValue( + "reportNowPlaying", + "BONOB_REPORT_NOW_PLAYING", + true, + (config) => config.reportNowPlaying + ); }); diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index dcfab20..81e6697 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -21,6 +21,7 @@ import { Credentials } from "../src/music_service"; import makeServer from "../src/server"; import { Service, bonobService, SONOS_DISABLED } from "../src/sonos"; import supersoap from "./supersoap"; +import url, { URLBuilder } from "../src/url_builder"; class LoggedInSonosDriver { client: Client; @@ -41,9 +42,10 @@ class LoggedInSonosDriver { let next = path.shift(); while (next) { if (next != "root") { - const childIds = this.currentMetadata!.getMetadataResult.mediaCollection!.map( - (it) => it.id - ); + const childIds = + this.currentMetadata!.getMetadataResult.mediaCollection!.map( + (it) => it.id + ); if (!childIds.includes(next)) { throw `Expected to find a child element with id=${next} in order to browse, but found only ${childIds}`; } @@ -74,31 +76,50 @@ class LoggedInSonosDriver { class SonosDriver { server: Express; - rootUrl: string; + bonobUrl: URLBuilder; service: Service; - constructor(server: Express, rootUrl: string, service: Service) { + constructor(server: Express, bonobUrl: URLBuilder, service: Service) { this.server = server; - this.rootUrl = rootUrl; + this.bonobUrl = bonobUrl; this.service = service; } - stripServiceRoot = (url: string) => url.replace(this.rootUrl, ""); + extractPathname = (url: string) => new URL(url).pathname; + + async register() { + const action = await request(this.server) + .get(this.bonobUrl.append({ pathname: "/" }).pathname()) + .expect(200) + .then((response) => { + const m = response.text.match(/ action="(.*)" /i); + return m![1]!; + }); + + return request(this.server) + .post(action) + .type("form") + .send({}) + .expect(200) + .then((response) => + expect(response.text).toContain("Successfully registered") + ); + } async addService() { expect(this.service.authType).toEqual("AppLink"); await request(this.server) - .get(this.stripServiceRoot(this.service.strings!.uri!)) + .get(this.extractPathname(this.service.strings!.uri!)) .expect(200); await request(this.server) - .get(this.stripServiceRoot(this.service.presentation!.uri!)) + .get(this.extractPathname(this.service.presentation!.uri!)) .expect(200); const client = await createClientAsync(`${this.service.uri}?wsdl`, { endpoint: this.service.uri, - httpClient: supersoap(this.server, this.rootUrl), + httpClient: supersoap(this.server), }); return client @@ -109,12 +130,18 @@ class SonosDriver { ) .then(({ regUrl, linkCode }: { regUrl: string; linkCode: string }) => ({ login: async ({ username, password }: Credentials) => { - await request(this.server) - .get(this.stripServiceRoot(regUrl)) - .expect(200); + const action = await request(this.server) + .get(this.extractPathname(regUrl)) + .expect(200) + .then((response) => { + const m = response.text.match(/ action="(.*)" /i); + return m![1]!; + }); + + console.log(`posting to action ${action}`); return request(this.server) - .post(this.stripServiceRoot(regUrl)) + .post(action) .type("form") .send({ username, password, linkCode }) .then((response) => ({ @@ -140,92 +167,137 @@ class SonosDriver { } describe("scenarios", () => { - const bonobUrl = "http://localhost:1234"; - const bonob = bonobService("bonob", 123, bonobUrl); const musicService = new InMemoryMusicService().hasArtists( BOB_MARLEY, BLONDIE ); const linkCodes = new InMemoryLinkCodes(); - const server = makeServer( - SONOS_DISABLED, - bonob, - bonobUrl, - musicService, - linkCodes - ); - - const sonosDriver = new SonosDriver(server, bonobUrl, bonob); beforeEach(() => { musicService.clear(); linkCodes.clear(); }); - describe("adding the service", () => { - describe("when the user doesnt exists within the music service", () => { - const username = "invaliduser"; - const password = "invalidpassword"; - - it("should fail to sign up", async () => { - musicService.hasNoUsers(); - - await sonosDriver - .addService() - .then((it) => it.login({ username, password })) - .then((it) => it.expectFailure()); - - expect(linkCodes.count()).toEqual(1); + function itShouldBeAbleToAddTheService(sonosDriver: SonosDriver) { + describe("registering bonob with the sonos device", () => { + it("should complete successfully", async () => { + await sonosDriver.register(); }); }); - describe("when the user exists within the music service", () => { - const username = "validuser"; - const password = "validpassword"; + describe("adding the service", () => { + describe("when the user doesnt exists within the music service", () => { + const username = "invaliduser"; + const password = "invalidpassword"; - beforeEach(() => { - musicService.hasUser({ username, password }); - musicService.hasArtists(BLONDIE, BOB_MARLEY, MADONNA); + it("should fail to sign up", async () => { + musicService.hasNoUsers(); + + await sonosDriver + .addService() + .then((it) => it.login({ username, password })) + .then((it) => it.expectFailure()); + + expect(linkCodes.count()).toEqual(1); + }); }); - it("should successfuly sign up", async () => { - await sonosDriver - .addService() - .then((it) => it.login({ username, password })) - .then((it) => it.expectSuccess()); + describe("when the user exists within the music service", () => { + const username = "validuser"; + const password = "validpassword"; - expect(linkCodes.count()).toEqual(1); - }); + beforeEach(() => { + musicService.hasUser({ username, password }); + musicService.hasArtists(BLONDIE, BOB_MARLEY, MADONNA); + }); - it("should be able to list the artists", async () => { - await sonosDriver - .addService() - .then((it) => it.login({ username, password })) - .then((it) => it.expectSuccess()) - .then((it) => it.navigate("root", "artists")) - .then((it) => - it.expectTitles( - [BLONDIE, BOB_MARLEY, MADONNA].map( - (it) => it.name + it("should successfuly sign up", async () => { + await sonosDriver + .addService() + .then((it) => it.login({ username, password })) + .then((it) => it.expectSuccess()); + + expect(linkCodes.count()).toEqual(1); + }); + + it("should be able to list the artists", async () => { + await sonosDriver + .addService() + .then((it) => it.login({ username, password })) + .then((it) => it.expectSuccess()) + .then((it) => it.navigate("root", "artists")) + .then((it) => + it.expectTitles( + [BLONDIE, BOB_MARLEY, MADONNA].map((it) => it.name) ) - ) - ); - }); + ); + }); - it("should be able to list the albums", async () => { - await sonosDriver - .addService() - .then((it) => it.login({ username, password })) - .then((it) => it.expectSuccess()) - .then((it) => it.navigate("root", "albums")) - .then((it) => - it.expectTitles( - [...BLONDIE.albums, ...BOB_MARLEY.albums, ...MADONNA.albums].map( - (it) => it.name + it("should be able to list the albums", async () => { + await sonosDriver + .addService() + .then((it) => it.login({ username, password })) + .then((it) => it.expectSuccess()) + .then((it) => it.navigate("root", "albums")) + .then((it) => + it.expectTitles( + [ + ...BLONDIE.albums, + ...BOB_MARLEY.albums, + ...MADONNA.albums, + ].map((it) => it.name) ) - ) - ); + ); + }); }); }); + } + + describe("when the bonobUrl has no context path and no trailing slash", () => { + const bonobUrl = url("http://localhost:1234"); + const bonob = bonobService("bonob", 123, bonobUrl); + const server = makeServer( + SONOS_DISABLED, + bonob, + bonobUrl, + musicService, + linkCodes + ); + + const sonosDriver = new SonosDriver(server, bonobUrl, bonob); + + itShouldBeAbleToAddTheService(sonosDriver); + }); + + describe("when the bonobUrl has no context path, but does have a trailing slash", () => { + const bonobUrl = url("http://localhost:1234/"); + const bonob = bonobService("bonob", 123, bonobUrl); + const server = makeServer( + SONOS_DISABLED, + bonob, + bonobUrl, + musicService, + linkCodes + ); + + const sonosDriver = new SonosDriver(server, bonobUrl, bonob); + + itShouldBeAbleToAddTheService(sonosDriver); + }); + + describe("when the bonobUrl has a context path", () => { + const bonobUrl = url("http://localhost:1234/context-for-bonob"); + const bonob = bonobService("bonob", 123, bonobUrl); + const server = makeServer( + SONOS_DISABLED, + bonob, + bonobUrl, + musicService, + linkCodes + ); + + const sonosDriver = new SonosDriver(server, bonobUrl, bonob); + + itShouldBeAbleToAddTheService(sonosDriver); }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index 94a1b20..86cb6f3 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -2,7 +2,11 @@ import { v4 as uuid } from "uuid"; import dayjs from "dayjs"; import request from "supertest"; import { MusicService } from "../src/music_service"; -import makeServer, { BONOB_ACCESS_TOKEN_HEADER, RangeBytesFromFilter, rangeFilterFor } from "../src/server"; +import makeServer, { + BONOB_ACCESS_TOKEN_HEADER, + RangeBytesFromFilter, + rangeFilterFor, +} from "../src/server"; import { SONOS_DISABLED, Sonos, Device } from "../src/sonos"; import { aDevice, aService } from "./builders"; @@ -11,6 +15,7 @@ import { ExpiringAccessTokens } from "../src/access_tokens"; import { InMemoryLinkCodes } from "../src/link_codes"; import { Response } from "express"; import { Transform } from "stream"; +import url from "../src/url_builder"; describe("rangeFilterFor", () => { describe("invalid range header string", () => { @@ -24,12 +29,13 @@ describe("rangeFilterFor", () => { "seconds", "seconds=0", "seconds=-", - ] + ]; for (let range in cases) { - expect(() => rangeFilterFor(range)).toThrowError(`Unsupported range: ${range}`); + expect(() => rangeFilterFor(range)).toThrowError( + `Unsupported range: ${range}` + ); } - }); }); @@ -46,7 +52,7 @@ describe("rangeFilterFor", () => { describe("64-", () => { it("should return a RangeBytesFromFilter", () => { - const filter = rangeFilterFor("bytes=64-") + const filter = rangeFilterFor("bytes=64-"); expect(filter instanceof RangeBytesFromFilter).toEqual(true); expect((filter as RangeBytesFromFilter).from).toEqual(64); @@ -56,19 +62,25 @@ describe("rangeFilterFor", () => { describe("-900", () => { it("should fail", () => { - expect(() => rangeFilterFor("bytes=-900")).toThrowError("Unsupported range: bytes=-900") + expect(() => rangeFilterFor("bytes=-900")).toThrowError( + "Unsupported range: bytes=-900" + ); }); }); describe("100-200", () => { it("should fail", () => { - expect(() => rangeFilterFor("bytes=100-200")).toThrowError("Unsupported range: bytes=100-200") + expect(() => rangeFilterFor("bytes=100-200")).toThrowError( + "Unsupported range: bytes=100-200" + ); }); }); describe("100-200, 400-500", () => { it("should fail", () => { - expect(() => rangeFilterFor("bytes=100-200, 400-500")).toThrowError("Unsupported range: bytes=100-200, 400-500") + expect(() => rangeFilterFor("bytes=100-200, 400-500")).toThrowError( + "Unsupported range: bytes=100-200, 400-500" + ); }); }); }); @@ -78,11 +90,13 @@ describe("rangeFilterFor", () => { const cases = [ "seconds=0-", "seconds=100-200", - "chickens=100-200, 400-500" - ] + "chickens=100-200, 400-500", + ]; for (let range in cases) { - expect(() => rangeFilterFor(range)).toThrowError(`Unsupported range: ${range}`); + expect(() => rangeFilterFor(range)).toThrowError( + `Unsupported range: ${range}` + ); } }); }); @@ -93,48 +107,48 @@ describe("RangeBytesFromFilter", () => { describe("0-", () => { it("should not filter at all", () => { const filter = new RangeBytesFromFilter(0); - const result: any[] = [] - + const result: any[] = []; + const callback = (_?: Error | null, data?: any) => { - if(data) result.push(...data!) - } + if (data) result.push(...data!); + }; - filter._transform(['a', 'b', 'c'], 'ascii', callback) - filter._transform(['d', 'e', 'f'], 'ascii', callback) + filter._transform(["a", "b", "c"], "ascii", callback); + filter._transform(["d", "e", "f"], "ascii", callback); - expect(result).toEqual(['a', 'b', 'c', 'd', 'e', 'f']) + expect(result).toEqual(["a", "b", "c", "d", "e", "f"]); }); }); describe("1-", () => { it("should filter the first byte", () => { const filter = new RangeBytesFromFilter(1); - const result: any[] = [] - + const result: any[] = []; + const callback = (_?: Error | null, data?: any) => { - if(data) result.push(...data!) - } + if (data) result.push(...data!); + }; - filter._transform(['a', 'b', 'c'], 'ascii', callback) - filter._transform(['d', 'e', 'f'], 'ascii', callback) + filter._transform(["a", "b", "c"], "ascii", callback); + filter._transform(["d", "e", "f"], "ascii", callback); - expect(result).toEqual(['b', 'c', 'd', 'e', 'f']) + expect(result).toEqual(["b", "c", "d", "e", "f"]); }); }); describe("5-", () => { it("should filter the first byte", () => { const filter = new RangeBytesFromFilter(5); - const result: any[] = [] - + const result: any[] = []; + const callback = (_?: Error | null, data?: any) => { - if(data) result.push(...data!) - } + if (data) result.push(...data!); + }; - filter._transform(['a', 'b', 'c'], 'ascii', callback) - filter._transform(['d', 'e', 'f'], 'ascii', callback) + filter._transform(["a", "b", "c"], "ascii", callback); + filter._transform(["d", "e", "f"], "ascii", callback); - expect(result).toEqual(['f']) + expect(result).toEqual(["f"]); }); }); }); @@ -146,753 +160,819 @@ describe("server", () => { jest.resetAllMocks(); }); - describe("/", () => { - describe("when sonos integration is disabled", () => { - const server = makeServer( - SONOS_DISABLED, - aService(), - "http://localhost:1234", - new InMemoryMusicService() - ); + const bonobUrlWithNoContextPath = url("http://bonob.localhost:1234"); + const bonobUrlWithContextPath = url("http://bonob.localhost:1234/aContext"); - describe("devices list", () => { - it("should be empty", async () => { - const res = await request(server).get("/").send(); + [bonobUrlWithNoContextPath, bonobUrlWithContextPath].forEach((bonobUrl) => { + describe(`a bonobUrl of ${bonobUrl}`, () => { + describe("/", () => { + describe("when sonos integration is disabled", () => { + const server = makeServer( + SONOS_DISABLED, + aService(), + bonobUrl, + new InMemoryMusicService() + ); - expect(res.status).toEqual(200); - expect(res.text).not.toMatch(/class=device/); - }); - }); - }); + describe("devices list", () => { + it("should be empty", async () => { + const res = await request(server) + .get(bonobUrl.append({ pathname: "/" }).pathname()) + .send(); - describe("when there are 2 devices and bonob is not registered", () => { - const service1 = aService({ - name: "s1", - sid: 1, - }); - const service2 = aService({ - name: "s2", - sid: 2, - }); - const service3 = aService({ - name: "s3", - sid: 3, - }); - const service4 = aService({ - name: "s4", - sid: 4, - }); - const missingBonobService = aService({ - name: "bonobMissing", - sid: 88, - }); - - const device1: Device = aDevice({ - name: "device1", - ip: "172.0.0.1", - port: 4301, - }); - - const device2: Device = aDevice({ - name: "device2", - ip: "172.0.0.2", - port: 4302, - }); - - const fakeSonos: Sonos = { - devices: () => Promise.resolve([device1, device2]), - services: () => - Promise.resolve([service1, service2, service3, service4]), - register: () => Promise.resolve(false), - }; - - const server = makeServer( - fakeSonos, - missingBonobService, - "http://localhost:1234", - new InMemoryMusicService() - ); - - describe("devices list", () => { - it("should contain the devices returned from sonos", async () => { - const res = await request(server).get("/").send(); - - expect(res.status).toEqual(200); - expect(res.text).toMatch(/device1\s+\(172.0.0.1:4301\)/); - expect(res.text).toMatch(/device2\s+\(172.0.0.2:4302\)/); - }); - }); - - describe("services", () => { - it("should contain a list of services returned from sonos", async () => { - const res = await request(server).get("/").send(); - - expect(res.status).toEqual(200); - expect(res.text).toMatch(/Services\s+4/); - expect(res.text).toMatch(/s1\s+\(1\)/); - expect(res.text).toMatch(/s2\s+\(2\)/); - expect(res.text).toMatch(/s3\s+\(3\)/); - expect(res.text).toMatch(/s4\s+\(4\)/); - }); - }); - - describe("registration status", () => { - it("should be not-registered", async () => { - const res = await request(server).get("/").send(); - expect(res.status).toEqual(200); - expect(res.text).toMatch(/No existing service registration/); - }); - }); - }); - - describe("when there are 2 devices and bonob is registered", () => { - const service1 = aService(); - - const service2 = aService(); - - const bonobService = aService({ - name: "bonobNotMissing", - sid: 99, - }); - - const fakeSonos: Sonos = { - devices: () => Promise.resolve([]), - services: () => Promise.resolve([service1, service2, bonobService]), - register: () => Promise.resolve(false), - }; - - const server = makeServer( - fakeSonos, - bonobService, - "http://localhost:1234", - new InMemoryMusicService() - ); - - describe("registration status", () => { - it("should be registered", async () => { - const res = await request(server).get("/").send(); - expect(res.status).toEqual(200); - expect(res.text).toMatch(/Existing service config/); - }); - }); - }); - }); - - describe("/register", () => { - const sonos = { - register: jest.fn(), - }; - const theService = aService({ - name: "We can all live a life of service", - sid: 999, - }); - const server = makeServer( - sonos as unknown as Sonos, - theService, - "http://localhost:1234", - new InMemoryMusicService() - ); - - describe("when is succesfull", () => { - it("should return a nice message", async () => { - sonos.register.mockResolvedValue(true); - - const res = await request(server).post("/register").send(); - - expect(res.status).toEqual(200); - expect(res.text).toMatch("Successfully registered"); - - expect(sonos.register.mock.calls.length).toEqual(1); - expect(sonos.register.mock.calls[0][0]).toBe(theService); - }); - }); - - describe("when is unsuccesfull", () => { - it("should return a failure message", async () => { - sonos.register.mockResolvedValue(false); - - const res = await request(server).post("/register").send(); - - expect(res.status).toEqual(500); - expect(res.text).toMatch("Registration failed!"); - - expect(sonos.register.mock.calls.length).toEqual(1); - expect(sonos.register.mock.calls[0][0]).toBe(theService); - }); - }); - }); - - describe("/stream", () => { - const musicService = { - login: jest.fn(), - }; - const musicLibrary = { - stream: jest.fn(), - scrobble: jest.fn(), - nowPlaying: jest.fn(), - }; - let now = dayjs(); - const accessTokens = new ExpiringAccessTokens({ now: () => now }); - - const server = makeServer( - jest.fn() as unknown as Sonos, - aService(), - "http://localhost:1234", - musicService as unknown as MusicService, - new InMemoryLinkCodes(), - accessTokens - ); - - const authToken = uuid(); - const trackId = uuid(); - let accessToken: string; - - beforeEach(() => { - accessToken = accessTokens.mint(authToken); - }); - - const streamContent = (content: string) => ({ - pipe: (_: Transform) => { - return { - pipe: (res: Response) => { - res.send(content); - } - } - }, - }) - - describe("HEAD requests", () => { - describe("when there is no access-token", () => { - it("should return a 401", async () => { - const res = await request(server).head(`/stream/track/${trackId}`); - - expect(res.status).toEqual(401); - }); - }); - - describe("when the access-token has expired", () => { - it("should return a 401", async () => { - now = now.add(1, "day"); - - const res = await request(server) - .head(`/stream/track/${trackId}`) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(401); - }); - }); - - describe("when the access-token is valid", () => { - describe("and the track exists", () => { - it("should return a 200", async () => { - const trackStream = { - status: 200, - headers: { - "content-type": "audio/mp3; charset=utf-8", - "content-length": "123", - }, - stream: streamContent(""), - }; - - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(trackStream); - - const res = await request(server) - .head(`/stream/track/${trackId}`) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(trackStream.status); - expect(res.headers["content-type"]).toEqual( - "audio/mp3; charset=utf-8" - ); - expect(res.headers["content-length"]).toEqual( - "123" - ); - expect(res.body).toEqual({}); + expect(res.status).toEqual(200); + expect(res.text).not.toMatch(/class=device/); + }); }); }); - describe("and the track doesnt exist", () => { - it("should return a 404", async () => { - const trackStream = { - status: 404, - headers: {}, - stream: streamContent(""), - }; - - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(trackStream); - - const res = await request(server) - .head(`/stream/track/${trackId}`) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(404); - expect(res.body).toEqual({}); + describe("when there are 2 devices and bonob is not registered", () => { + const service1 = aService({ + name: "s1", + sid: 1, + }); + const service2 = aService({ + name: "s2", + sid: 2, + }); + const service3 = aService({ + name: "s3", + sid: 3, + }); + const service4 = aService({ + name: "s4", + sid: 4, + }); + const missingBonobService = aService({ + name: "bonobMissing", + sid: 88, }); - }); - }); - }); - describe("GET requests", () => { - describe("when there is no access-token", () => { - it("should return a 401", async () => { - const res = await request(server).get(`/stream/track/${trackId}`); + const device1: Device = aDevice({ + name: "device1", + ip: "172.0.0.1", + port: 4301, + }); - expect(res.status).toEqual(401); - }); - }); + const device2: Device = aDevice({ + name: "device2", + ip: "172.0.0.2", + port: 4302, + }); - describe("when the access-token has expired", () => { - it("should return a 401", async () => { - now = now.add(1, "day"); - - const res = await request(server) - .get(`/stream/track/${trackId}`) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(401); - }); - }); - - describe("when the track doesnt exist", () => { - it("should return a 404", async () => { - const stream = { - status: 404, - headers: { - }, - stream: streamContent(""), + const fakeSonos: Sonos = { + devices: () => Promise.resolve([device1, device2]), + services: () => + Promise.resolve([service1, service2, service3, service4]), + register: () => Promise.resolve(false), }; - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(stream); + const server = makeServer( + fakeSonos, + missingBonobService, + bonobUrl, + new InMemoryMusicService() + ); - const res = await request(server) - .get(`/stream/track/${trackId}`) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + describe("devices list", () => { + it("should contain the devices returned from sonos", async () => { + const res = await request(server) + .get(bonobUrl.append({ pathname: "/" }).path()) + .send(); - expect(res.status).toEqual(404); - - expect(musicLibrary.nowPlaying).not.toHaveBeenCalled(); - expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); - }); - }); - - describe("when sonos does not ask for a range", () => { - describe("when the music service does not return a content-range, content-length or accept-ranges", () => { - it("should return a 200 with the data, without adding the undefined headers", async () => { - const content = "some-track"; - - const stream = { - status: 200, - headers: { - "content-type": "audio/mp3", - }, - stream: streamContent(content), - }; - - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(stream); - musicLibrary.nowPlaying.mockResolvedValue(true); - - const res = await request(server) - .get(`/stream/track/${trackId}`) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(stream.status); - expect(res.headers["content-type"]).toEqual( - "audio/mp3; charset=utf-8" - ); - expect(res.header["accept-ranges"]).toBeUndefined(); - expect(res.headers["content-length"]).toEqual( - `${content.length}` - ); - expect(Object.keys(res.headers)).not.toContain("content-range"); - - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); - expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + expect(res.status).toEqual(200); + expect(res.text).toMatch(/device1\s+\(172.0.0.1:4301\)/); + expect(res.text).toMatch(/device2\s+\(172.0.0.2:4302\)/); + }); }); - }); - describe("when the music service returns undefined values for content-range, content-length or accept-ranges", () => { - it("should return a 200 with the data, without adding the undefined headers", async () => { - const stream = { - status: 200, - headers: { - "content-type": "audio/mp3", - "content-length": undefined, - "accept-ranges": undefined, - "content-range": undefined, - }, - stream: streamContent("") - }; + describe("services", () => { + it("should contain a list of services returned from sonos", async () => { + const res = await request(server) + .get(bonobUrl.append({ pathname: "/" }).path()) + .send(); - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(stream); - musicLibrary.nowPlaying.mockResolvedValue(true); - - const res = await request(server) - .get(`/stream/track/${trackId}`) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(stream.status); - expect(res.headers["content-type"]).toEqual( - "audio/mp3; charset=utf-8" - ); - expect(res.header["accept-ranges"]).toEqual( - stream.headers["accept-ranges"] - ); - expect(Object.keys(res.headers)).not.toContain("content-range"); - - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); - expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + expect(res.status).toEqual(200); + expect(res.text).toMatch(/Services\s+4/); + expect(res.text).toMatch(/s1\s+\(1\)/); + expect(res.text).toMatch(/s2\s+\(2\)/); + expect(res.text).toMatch(/s3\s+\(3\)/); + expect(res.text).toMatch(/s4\s+\(4\)/); + }); }); - }); - describe("when the music service returns a 200", () => { - it("should return a 200 with the data", async () => { - const stream = { - status: 200, - headers: { - "content-type": "audio/mp3", - "content-length": "222", - "accept-ranges": "bytes", - }, - stream: streamContent("") - }; - - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(stream); - musicLibrary.nowPlaying.mockResolvedValue(true); - - const res = await request(server) - .get(`/stream/track/${trackId}`) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(stream.status); - expect(res.header["content-type"]).toEqual( - `${stream.headers["content-type"]}; charset=utf-8` - ); - expect(res.header["accept-ranges"]).toEqual( - stream.headers["accept-ranges"] - ); - expect(res.header["content-range"]).toBeUndefined(); - - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); - expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); - }); - }); - - describe("when the music service returns a 206", () => { - it("should return a 206 with the data", async () => { - const stream = { - status: 206, - headers: { - "content-type": "audio/ogg", - "content-length": "333", - "accept-ranges": "bytez", - "content-range": "100-200", - }, - stream: streamContent("") - }; - - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(stream); - musicLibrary.nowPlaying.mockResolvedValue(true); - - const res = await request(server) - .get(`/stream/track/${trackId}`) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(stream.status); - expect(res.header["content-type"]).toEqual( - `${stream.headers["content-type"]}; charset=utf-8` - ); - expect(res.header["accept-ranges"]).toEqual( - stream.headers["accept-ranges"] - ); - expect(res.header["content-range"]).toEqual( - stream.headers["content-range"] - ); - - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); - expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); - }); - }); - }); - - describe("when sonos does ask for a range", () => { - describe("when the music service returns a 200", () => { - it("should return a 200 with the data", async () => { - const stream = { - status: 200, - headers: { - "content-type": "audio/mp3", - "content-length": "222", - "accept-ranges": "none", - }, - stream: streamContent("") - }; - - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(stream); - musicLibrary.nowPlaying.mockResolvedValue(true); - - const requestedRange = "40-"; - - const res = await request(server) - .get(`/stream/track/${trackId}`) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken) - .set("Range", requestedRange); - - expect(res.status).toEqual(stream.status); - expect(res.header["content-type"]).toEqual( - `${stream.headers["content-type"]}; charset=utf-8` - ); - expect(res.header["accept-ranges"]).toEqual( - stream.headers["accept-ranges"] - ); - expect(res.header["content-range"]).toBeUndefined(); - - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); - expect(musicLibrary.stream).toHaveBeenCalledWith({ - trackId, - range: requestedRange, + describe("registration status", () => { + it("should be not-registered", async () => { + const res = await request(server) + .get(bonobUrl.append({ pathname: "/" }).path()) + .send(); + expect(res.status).toEqual(200); + expect(res.text).toMatch(/No existing service registration/); }); }); }); - describe("when the music service returns a 206", () => { - it("should return a 206 with the data", async () => { - const stream = { - status: 206, - headers: { - "content-type": "audio/ogg", - "content-length": "333", - "accept-ranges": "bytez", - "content-range": "100-200", - }, - stream: streamContent("") - }; + describe("when there are 2 devices and bonob is registered", () => { + const service1 = aService(); - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.stream.mockResolvedValue(stream); - musicLibrary.nowPlaying.mockResolvedValue(true); + const service2 = aService(); - const res = await request(server) - .get(`/stream/track/${trackId}`) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken) - .set("Range", "4000-5000"); + const bonobService = aService({ + name: "bonobNotMissing", + sid: 99, + }); - expect(res.status).toEqual(stream.status); - expect(res.header["content-type"]).toEqual( - `${stream.headers["content-type"]}; charset=utf-8` - ); - expect(res.header["accept-ranges"]).toEqual( - stream.headers["accept-ranges"] - ); - expect(res.header["content-range"]).toEqual( - stream.headers["content-range"] - ); + const fakeSonos: Sonos = { + devices: () => Promise.resolve([]), + services: () => Promise.resolve([service1, service2, bonobService]), + register: () => Promise.resolve(false), + }; - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); - expect(musicLibrary.stream).toHaveBeenCalledWith({ - trackId, - range: "4000-5000", + const server = makeServer( + fakeSonos, + bonobService, + bonobUrl, + new InMemoryMusicService() + ); + + describe("registration status", () => { + it("should be registered", async () => { + const res = await request(server) + .get(bonobUrl.append({ pathname: "/" }).path()) + .send(); + expect(res.status).toEqual(200); + expect(res.text).toMatch(/Existing service config/); }); }); }); }); - }); - }); - describe("art", () => { - const musicService = { - login: jest.fn(), - }; - const musicLibrary = { - coverArt: jest.fn(), - }; - let now = dayjs(); - const accessTokens = new ExpiringAccessTokens({ now: () => now }); - - const server = makeServer( - jest.fn() as unknown as Sonos, - aService(), - "http://localhost:1234", - musicService as unknown as MusicService, - new InMemoryLinkCodes(), - accessTokens - ); - - const authToken = uuid(); - const albumId = uuid(); - let accessToken: string; - - beforeEach(() => { - accessToken = accessTokens.mint(authToken); - }); - - describe("when there is no access-token", () => { - it("should return a 401", async () => { - const res = await request(server).get(`/album/123/art/size/180`); - - expect(res.status).toEqual(401); - }); - }); - - describe("when the access-token has expired", () => { - it("should return a 401", async () => { - now = now.add(1, "day"); - - const res = await request(server).get( - `/album/123/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + describe("/register", () => { + const sonos = { + register: jest.fn(), + }; + const theService = aService({ + name: "We can all live a life of service", + sid: 999, + }); + const server = makeServer( + sonos as unknown as Sonos, + theService, + bonobUrl, + new InMemoryMusicService() ); - expect(res.status).toEqual(401); - }); - }); - - describe("when there is a valid access token", () => { - describe("some invalid art type", () => { - it("should return a 400", async () => { - const res = await request(server) - .get( - `/foo/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(400); - }); - }); - - describe("artist art", () => { - describe("when there is some", () => { - it("should return the image and a 200", async () => { - const coverArt = { - status: 200, - contentType: "image/jpeg", - data: Buffer.from("some image", "ascii"), - }; - - musicService.login.mockResolvedValue(musicLibrary); - - musicLibrary.coverArt.mockResolvedValue(coverArt); + describe("when is succesfull", () => { + it("should return a nice message", async () => { + sonos.register.mockResolvedValue(true); const res = await request(server) - .get( - `/artist/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + .post(bonobUrl.append({ pathname: "/register" }).path()) + .send(); - expect(res.status).toEqual(coverArt.status); - expect(res.header["content-type"]).toEqual(coverArt.contentType); + expect(res.status).toEqual(200); + expect(res.text).toMatch("Successfully registered"); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.coverArt).toHaveBeenCalledWith( - albumId, - "artist", - 180 - ); + expect(sonos.register.mock.calls.length).toEqual(1); + expect(sonos.register.mock.calls[0][0]).toBe(theService); }); }); - describe("when there isn't one", () => { - it("should return a 404", async () => { - musicService.login.mockResolvedValue(musicLibrary); - - musicLibrary.coverArt.mockResolvedValue(undefined); + describe("when is unsuccesfull", () => { + it("should return a failure message", async () => { + sonos.register.mockResolvedValue(false); const res = await request(server) - .get( - `/artist/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(404); - }); - }); - - describe("when there is an error", () => { - it("should return a 500", async () => { - musicService.login.mockResolvedValue(musicLibrary); - - musicLibrary.coverArt.mockRejectedValue("Boom"); - - const res = await request(server) - .get( - `/artist/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + .post(bonobUrl.append({ pathname: "/register" }).path()) + .send(); expect(res.status).toEqual(500); + expect(res.text).toMatch("Registration failed!"); + + expect(sonos.register.mock.calls.length).toEqual(1); + expect(sonos.register.mock.calls[0][0]).toBe(theService); }); }); }); - describe("album art", () => { - describe("when there is some", () => { - it("should return the image and a 200", async () => { - const coverArt = { - status: 200, - contentType: "image/jpeg", - data: Buffer.from("some image", "ascii"), + describe("/stream", () => { + const musicService = { + login: jest.fn(), + }; + const musicLibrary = { + stream: jest.fn(), + scrobble: jest.fn(), + nowPlaying: jest.fn(), + }; + let now = dayjs(); + const accessTokens = new ExpiringAccessTokens({ now: () => now }); + + const server = makeServer( + jest.fn() as unknown as Sonos, + aService(), + bonobUrl, + musicService as unknown as MusicService, + new InMemoryLinkCodes(), + accessTokens + ); + + const authToken = uuid(); + const trackId = uuid(); + let accessToken: string; + + beforeEach(() => { + accessToken = accessTokens.mint(authToken); + }); + + const streamContent = (content: string) => ({ + pipe: (_: Transform) => { + return { + pipe: (res: Response) => { + res.send(content); + }, }; + }, + }); - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.coverArt.mockResolvedValue(coverArt); + describe("HEAD requests", () => { + describe("when there is no access-token", () => { + it("should return a 401", async () => { + const res = await request(server).head( + bonobUrl.append({ pathname: `/stream/track/${trackId}` }).path() + ); - const res = await request(server) - .get( - `/album/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + expect(res.status).toEqual(401); + }); + }); - expect(res.status).toEqual(coverArt.status); - expect(res.header["content-type"]).toEqual(coverArt.contentType); + describe("when the access-token has expired", () => { + it("should return a 401", async () => { + now = now.add(1, "day"); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(musicLibrary.coverArt).toHaveBeenCalledWith( - albumId, - "album", - 180 + const res = await request(server) + .head( + bonobUrl + .append({ pathname: `/stream/track/${trackId}` }) + .path() + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(401); + }); + }); + + describe("when the access-token is valid", () => { + describe("and the track exists", () => { + it("should return a 200", async () => { + const trackStream = { + status: 200, + headers: { + "content-type": "audio/mp3; charset=utf-8", + "content-length": "123", + }, + stream: streamContent(""), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(trackStream); + + const res = await request(server) + .head( + bonobUrl + .append({ pathname: `/stream/track/${trackId}` }) + .path() + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(trackStream.status); + expect(res.headers["content-type"]).toEqual( + "audio/mp3; charset=utf-8" + ); + expect(res.headers["content-length"]).toEqual("123"); + expect(res.body).toEqual({}); + }); + }); + + describe("and the track doesnt exist", () => { + it("should return a 404", async () => { + const trackStream = { + status: 404, + headers: {}, + stream: streamContent(""), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(trackStream); + + const res = await request(server) + .head(`/stream/track/${trackId}`) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(404); + expect(res.body).toEqual({}); + }); + }); + }); + }); + + describe("GET requests", () => { + describe("when there is no access-token", () => { + it("should return a 401", async () => { + const res = await request(server).get( + bonobUrl.append({ pathname: `/stream/track/${trackId}` }).path() + ); + + expect(res.status).toEqual(401); + }); + }); + + describe("when the access-token has expired", () => { + it("should return a 401", async () => { + now = now.add(1, "day"); + + const res = await request(server) + .get( + bonobUrl + .append({ pathname: `/stream/track/${trackId}` }) + .path() + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(401); + }); + }); + + describe("when the track doesnt exist", () => { + it("should return a 404", async () => { + const stream = { + status: 404, + headers: {}, + stream: streamContent(""), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + + const res = await request(server) + .get( + bonobUrl + .append({ pathname: `/stream/track/${trackId}` }) + .path() + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(404); + + expect(musicLibrary.nowPlaying).not.toHaveBeenCalled(); + expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + }); + }); + + describe("when sonos does not ask for a range", () => { + describe("when the music service does not return a content-range, content-length or accept-ranges", () => { + it("should return a 200 with the data, without adding the undefined headers", async () => { + const content = "some-track"; + + const stream = { + status: 200, + headers: { + "content-type": "audio/mp3", + }, + stream: streamContent(content), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); + + const res = await request(server) + .get( + bonobUrl + .append({ pathname: `/stream/track/${trackId}` }) + .path() + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(stream.status); + expect(res.headers["content-type"]).toEqual( + "audio/mp3; charset=utf-8" + ); + expect(res.header["accept-ranges"]).toBeUndefined(); + expect(res.headers["content-length"]).toEqual( + `${content.length}` + ); + expect(Object.keys(res.headers)).not.toContain("content-range"); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); + expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + }); + }); + + describe("when the music service returns undefined values for content-range, content-length or accept-ranges", () => { + it("should return a 200 with the data, without adding the undefined headers", async () => { + const stream = { + status: 200, + headers: { + "content-type": "audio/mp3", + "content-length": undefined, + "accept-ranges": undefined, + "content-range": undefined, + }, + stream: streamContent(""), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); + + const res = await request(server) + .get( + bonobUrl + .append({ pathname: `/stream/track/${trackId}` }) + .path() + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(stream.status); + expect(res.headers["content-type"]).toEqual( + "audio/mp3; charset=utf-8" + ); + expect(res.header["accept-ranges"]).toEqual( + stream.headers["accept-ranges"] + ); + expect(Object.keys(res.headers)).not.toContain("content-range"); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); + expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + }); + }); + + describe("when the music service returns a 200", () => { + it("should return a 200 with the data", async () => { + const stream = { + status: 200, + headers: { + "content-type": "audio/mp3", + "content-length": "222", + "accept-ranges": "bytes", + }, + stream: streamContent(""), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); + + const res = await request(server) + .get( + bonobUrl + .append({ pathname: `/stream/track/${trackId}` }) + .path() + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(stream.status); + expect(res.header["content-type"]).toEqual( + `${stream.headers["content-type"]}; charset=utf-8` + ); + expect(res.header["accept-ranges"]).toEqual( + stream.headers["accept-ranges"] + ); + expect(res.header["content-range"]).toBeUndefined(); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); + expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + }); + }); + + describe("when the music service returns a 206", () => { + it("should return a 206 with the data", async () => { + const stream = { + status: 206, + headers: { + "content-type": "audio/ogg", + "content-length": "333", + "accept-ranges": "bytez", + "content-range": "100-200", + }, + stream: streamContent(""), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); + + const res = await request(server) + .get( + bonobUrl + .append({ pathname: `/stream/track/${trackId}` }) + .path() + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(stream.status); + expect(res.header["content-type"]).toEqual( + `${stream.headers["content-type"]}; charset=utf-8` + ); + expect(res.header["accept-ranges"]).toEqual( + stream.headers["accept-ranges"] + ); + expect(res.header["content-range"]).toEqual( + stream.headers["content-range"] + ); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); + expect(musicLibrary.stream).toHaveBeenCalledWith({ trackId }); + }); + }); + }); + + describe("when sonos does ask for a range", () => { + describe("when the music service returns a 200", () => { + it("should return a 200 with the data", async () => { + const stream = { + status: 200, + headers: { + "content-type": "audio/mp3", + "content-length": "222", + "accept-ranges": "none", + }, + stream: streamContent(""), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); + + const requestedRange = "40-"; + + const res = await request(server) + .get( + bonobUrl + .append({ pathname: `/stream/track/${trackId}` }) + .path() + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken) + .set("Range", requestedRange); + + expect(res.status).toEqual(stream.status); + expect(res.header["content-type"]).toEqual( + `${stream.headers["content-type"]}; charset=utf-8` + ); + expect(res.header["accept-ranges"]).toEqual( + stream.headers["accept-ranges"] + ); + expect(res.header["content-range"]).toBeUndefined(); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); + expect(musicLibrary.stream).toHaveBeenCalledWith({ + trackId, + range: requestedRange, + }); + }); + }); + + describe("when the music service returns a 206", () => { + it("should return a 206 with the data", async () => { + const stream = { + status: 206, + headers: { + "content-type": "audio/ogg", + "content-length": "333", + "accept-ranges": "bytez", + "content-range": "100-200", + }, + stream: streamContent(""), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.stream.mockResolvedValue(stream); + musicLibrary.nowPlaying.mockResolvedValue(true); + + const res = await request(server) + .get( + bonobUrl + .append({ pathname: `/stream/track/${trackId}` }) + .path() + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken) + .set("Range", "4000-5000"); + + expect(res.status).toEqual(stream.status); + expect(res.header["content-type"]).toEqual( + `${stream.headers["content-type"]}; charset=utf-8` + ); + expect(res.header["accept-ranges"]).toEqual( + stream.headers["accept-ranges"] + ); + expect(res.header["content-range"]).toEqual( + stream.headers["content-range"] + ); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); + expect(musicLibrary.stream).toHaveBeenCalledWith({ + trackId, + range: "4000-5000", + }); + }); + }); + }); + }); + }); + + describe("art", () => { + const musicService = { + login: jest.fn(), + }; + const musicLibrary = { + coverArt: jest.fn(), + }; + let now = dayjs(); + const accessTokens = new ExpiringAccessTokens({ now: () => now }); + + const server = makeServer( + jest.fn() as unknown as Sonos, + aService(), + url("http://localhost:1234"), + musicService as unknown as MusicService, + new InMemoryLinkCodes(), + accessTokens + ); + + const authToken = uuid(); + const albumId = uuid(); + let accessToken: string; + + beforeEach(() => { + accessToken = accessTokens.mint(authToken); + }); + + describe("when there is no access-token", () => { + it("should return a 401", async () => { + const res = await request(server).get(`/album/123/art/size/180`); + + expect(res.status).toEqual(401); + }); + }); + + describe("when the access-token has expired", () => { + it("should return a 401", async () => { + now = now.add(1, "day"); + + const res = await request(server).get( + `/album/123/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` ); + + expect(res.status).toEqual(401); }); }); - describe("when there isnt any", () => { - it("should return a 404", async () => { - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.coverArt.mockResolvedValue(undefined); + describe("when there is a valid access token", () => { + describe("some invalid art type", () => { + it("should return a 400", async () => { + const res = await request(server) + .get( + `/foo/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - const res = await request(server) - .get( - `/album/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); - - expect(res.status).toEqual(404); + expect(res.status).toEqual(400); + }); }); - }); - describe("when there is an error", () => { - it("should return a 500", async () => { - musicService.login.mockResolvedValue(musicLibrary); - musicLibrary.coverArt.mockRejectedValue("Boooooom"); + describe("artist art", () => { + describe("when there is some", () => { + it("should return the image and a 200", async () => { + const coverArt = { + status: 200, + contentType: "image/jpeg", + data: Buffer.from("some image", "ascii"), + }; - const res = await request(server) - .get( - `/album/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + musicService.login.mockResolvedValue(musicLibrary); - expect(res.status).toEqual(500); + musicLibrary.coverArt.mockResolvedValue(coverArt); + + const res = await request(server) + .get( + `/artist/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(coverArt.status); + expect(res.header["content-type"]).toEqual( + coverArt.contentType + ); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.coverArt).toHaveBeenCalledWith( + albumId, + "artist", + 180 + ); + }); + }); + + describe("when there isn't one", () => { + it("should return a 404", async () => { + musicService.login.mockResolvedValue(musicLibrary); + + musicLibrary.coverArt.mockResolvedValue(undefined); + + const res = await request(server) + .get( + `/artist/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(404); + }); + }); + + describe("when there is an error", () => { + it("should return a 500", async () => { + musicService.login.mockResolvedValue(musicLibrary); + + musicLibrary.coverArt.mockRejectedValue("Boom"); + + const res = await request(server) + .get( + `/artist/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(500); + }); + }); + }); + + describe("album art", () => { + describe("when there is some", () => { + it("should return the image and a 200", async () => { + const coverArt = { + status: 200, + contentType: "image/jpeg", + data: Buffer.from("some image", "ascii"), + }; + + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.coverArt.mockResolvedValue(coverArt); + + const res = await request(server) + .get( + `/album/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(coverArt.status); + expect(res.header["content-type"]).toEqual( + coverArt.contentType + ); + + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(musicLibrary.coverArt).toHaveBeenCalledWith( + albumId, + "album", + 180 + ); + }); + }); + + describe("when there isnt any", () => { + it("should return a 404", async () => { + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.coverArt.mockResolvedValue(undefined); + + const res = await request(server) + .get( + `/album/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(404); + }); + }); + + describe("when there is an error", () => { + it("should return a 500", async () => { + musicService.login.mockResolvedValue(musicLibrary); + musicLibrary.coverArt.mockRejectedValue("Boooooom"); + + const res = await request(server) + .get( + `/album/${albumId}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + ) + .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + + expect(res.status).toEqual(500); + }); + }); }); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 342a2e3..984fe80 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -8,7 +8,7 @@ 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 makeServer from "../src/server"; import { bonobService, SONOS_DISABLED } from "../src/sonos"; import { STRINGS_ROUTE, @@ -46,62 +46,73 @@ import { } from "../src/music_service"; import { AccessTokens } from "../src/access_tokens"; import dayjs from "dayjs"; +import url from "../src/url_builder"; 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() - ); + const bonobWithNoContextPath = url("http://localhost:1234"); + const bonobWithContextPath = url("http://localhost:5678/some-context-path"); - describe(STRINGS_ROUTE, () => { - it("should return xml for the strings", async () => { - const res = await request(server).get(STRINGS_ROUTE).send(); + [bonobWithNoContextPath, bonobWithContextPath].forEach((bonobUrl) => { + const server = makeServer( + SONOS_DISABLED, + aService({ name: "music land" }), + bonobUrl, + new InMemoryMusicService() + ); - 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" - ); + const stringsUrl = bonobUrl.append({ pathname: STRINGS_ROUTE }); + const presentationUrl = bonobUrl.append({ + pathname: PRESENTATION_MAP_ROUTE, }); - }); - 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(); + describe(`${stringsUrl}`, () => { + it("should return xml for the strings", async () => { + const res = await request(server).get(stringsUrl.path()).send(); - expect(res.status).toEqual(200); + 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 + // removing the sonos xml ns as makes xpath queries with xpath-ts painful + const xml = parseXML( + res.text.replace('xmlns="http://sonos.com/sonosapi"', "") ); - SONOS_RECOMMENDED_IMAGE_SIZES.forEach((size) => { - expect(imageSizeMap(size)).toEqual(`/art/size/${size}`); + 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(`${presentationUrl}`, () => { + it("should have an ArtWorkSizeMap for all sizes recommended by sonos", async () => { + const res = await request(server).get(presentationUrl.path()).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}`); + }); }); }); }); @@ -191,8 +202,7 @@ describe("getMetadataResult", () => { describe("track", () => { it("should map into a sonos expected track", () => { - const webAddress = "http://localhost:4567"; - const accessToken = uuid(); + const bonobUrl = url("http://localhost:4567/foo?access-token=1234"); const someTrack = aTrack({ id: uuid(), mimeType: "audio/something", @@ -207,7 +217,7 @@ describe("track", () => { artist: anArtist({ name: "great artist", id: uuid() }), }); - expect(track(webAddress, accessToken, someTrack)).toEqual({ + expect(track(bonobUrl, someTrack)).toEqual({ itemType: "track", id: `track:${someTrack.id}`, mimeType: someTrack.mimeType, @@ -218,7 +228,7 @@ describe("track", () => { 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}`, + albumArtURI: `http://localhost:4567/foo/album/${someTrack.album.id}/art/size/180?access-token=1234`, artist: someTrack.artist.name, artistId: someTrack.artist.id, duration: someTrack.duration, @@ -232,15 +242,14 @@ describe("track", () => { describe("album", () => { it("should map to a sonos album", () => { - const webAddress = "http://localhost:9988"; - const accessToken = uuid(); + const bonobUrl = url("http://localhost:9988/some-context-path?s=hello"); const someAlbum = anAlbum({ id: "id123", name: "What a great album" }); - expect(album(webAddress, accessToken, someAlbum)).toEqual({ + expect(album(bonobUrl, someAlbum)).toEqual({ itemType: "album", id: `album:${someAlbum.id}`, title: someAlbum.name, - albumArtURI: defaultAlbumArtURI(webAddress, accessToken, someAlbum), + albumArtURI: defaultAlbumArtURI(bonobUrl, someAlbum).href(), canPlay: true, artist: someAlbum.artistName, artistId: someAlbum.artistId, @@ -250,31 +259,27 @@ describe("album", () => { describe("defaultAlbumArtURI", () => { it("should create the correct URI", () => { - const webAddress = "http://localhost:1234"; - const accessToken = uuid(); + const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const album = anAlbum(); - expect(defaultAlbumArtURI(webAddress, accessToken, album)).toEqual( - `${webAddress}/album/${album.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + expect(defaultAlbumArtURI(bonobUrl, album).href()).toEqual( + `http://localhost:1234/context-path/album/${album.id}/art/size/180?search=yes` ); }); }); describe("defaultArtistArtURI", () => { it("should create the correct URI", () => { - const webAddress = "http://localhost:1234"; - const accessToken = uuid(); + const bonobUrl = url("http://localhost:1234/something?s=123"); const artist = anArtist(); - expect(defaultArtistArtURI(webAddress, accessToken, artist)).toEqual( - `${webAddress}/artist/${artist.id}/art/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` + expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual( + `http://localhost:1234/something/artist/${artist.id}/art/size/180?s=123` ); }); }); describe("api", () => { - const rootUrl = "http://localhost:1234"; - const service = bonobService("test-api", 133, rootUrl, "AppLink"); const musicService = { generateToken: jest.fn(), login: jest.fn(), @@ -312,2238 +317,2309 @@ describe("api", () => { 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 - ); + const bonobUrlWithoutContextPath = url("http://localhost:222"); + const bonobUrlWithContextPath = url("http://localhost:111/path/to/bonob"); - beforeEach(() => { - jest.clearAllMocks(); - jest.resetAllMocks(); - }); + [bonobUrlWithoutContextPath, bonobUrlWithContextPath].forEach((bonobUrl) => { + describe(`bonob with url ${bonobUrl}`, () => { + const authToken = `authToken-${uuid()}`; + const accessToken = `accessToken-${uuid()}`; - 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`, - }; + const bonobUrlWithAccessToken = bonobUrl.append({ + searchParams: { "bonob-access-token": accessToken }, + }); - linkCodes.has.mockReturnValue(true); - musicService.generateToken.mockResolvedValue(authToken); - linkCodes.associate.mockReturnValue(true); + const service = bonobService("test-api", 133, bonobUrl, "AppLink"); + const server = makeServer( + SONOS_DISABLED, + service, + bonobUrl, + musicService as unknown as MusicService, + linkCodes as unknown as LinkCodes, + accessTokens as unknown as AccessTokens, + clock + ); - const res = await request(server) - .post(LOGIN_ROUTE) - .type("form") - .send({ username, password, linkCode }) - .expect(200); + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); - expect(res.text).toContain("Login successful"); + describe("pages", () => { + describe(bonobUrl.append({ pathname: LOGIN_ROUTE }).href(), () => { + 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`, + }; - expect(musicService.generateToken).toHaveBeenCalledWith({ - username, - password, + linkCodes.has.mockReturnValue(true); + musicService.generateToken.mockResolvedValue(authToken); + linkCodes.associate.mockReturnValue(true); + + const res = await request(server) + .post(bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname()) + .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(bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname()) + .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(bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname()) + .type("form") + .send({ username, password, linkCode }) + .expect(400); + + expect(res.text).toContain("Invalid linkCode!"); + }); }); - 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}`; + describe("soap api", () => { + describe("getAppLink", () => { + it("should do something", async () => { + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); - linkCodes.has.mockReturnValue(true); - musicService.generateToken.mockResolvedValue({ message }); + const linkCode = "theLinkCode8899"; - const res = await request(server) - .post(LOGIN_ROUTE) - .type("form") - .send({ username, password, linkCode }) - .expect(403); + linkCodes.mint.mockReturnValue(linkCode); - expect(res.text).toContain(`Login failed! ${message}`); - }); - }); + const result = await ws.getAppLinkAsync(getAppLinkMessage()); - 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, + expect(result[0]).toEqual({ + getAppLinkResult: { + authorizeAccount: { + appUrlStringId: "AppLinkMessage", + deviceLink: { + regUrl: bonobUrl + .append({ + pathname: "/login", + searchParams: { linkCode }, + }) + .href(), + 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); + 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 ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); + + 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); + }); }); - const result = await ws.getDeviceAuthTokenAsync({ linkCode }); + describe("when there is no linkCode association", () => { + it("should return a device auth token", async () => { + const linkCode = "invalidLinkCode"; + linkCodes.associationFor.mockReturnValue(undefined); - expect(result[0]).toEqual({ - getDeviceAuthTokenResult: { - authToken: association.authToken, - privateKey: "", - userInfo: { - nickname: association.nickname, - userIdHashCode: crypto - .createHash("sha256") - .update(association.userId) - .digest("hex"), + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); + + 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), + }); + + const result = await ws.getLastUpdateAsync({}); + + expect(result[0]).toEqual({ + getLastUpdateResult: { + autoRefreshEnabled: true, + favorites: `${now.unix()}`, + catalog: `${now.unix()}`, + pollInterval: 60, }, - }, - }); - 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: { - autoRefreshEnabled: true, - favorites: `${now.unix()}`, - catalog: `${now.unix()}`, - pollInterval: 60, - }, - }); - }); - }); - - 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: "playlist", - id: "playlists", - title: "Playlists", - attributes: { - readOnly: "false", - renameable: "false", - userContent: "true", - }, - }, - { 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: "playlist", - id: `playlist:${playlist.id}`, - title: playlist.name, - canPlay: true, - attributes: { - readOnly: "false", - userContent: "false", - renameable: "false", - }, - })), - 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: "playlist", - id: `playlist:${playlist.id}`, - title: playlist.name, - canPlay: true, - attributes: { - readOnly: "false", - userContent: "false", - renameable: "false", - }, - })), - 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, + 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), }); - 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); + 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("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, + 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), }); - 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); + 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", () => { + 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), + }); + 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(bonobUrlWithAccessToken, 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(bonobUrlWithAccessToken, 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(bonobUrlWithAccessToken, it.album) + ), + index: 0, + total: 2, + }) + ); + expect(musicLibrary.searchTracks).toHaveBeenCalledWith(term); + }); }); }); }); - 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, + 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), }); - 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); + 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("asking for a page of artists", () => { - const index = 1; - const count = 3; + describe("when invalid credentials are provided", () => { + it("should return a fault of LoginUnauthorized", async () => { + musicService.login.mockRejectedValue("fail!"); - it("should return it", async () => { - const someArtists = [ - artistSummaries[1]!, - artistSummaries[2]!, - artistSummaries[3]!, - ]; - musicLibrary.artists.mockResolvedValue({ - results: someArtists, - total: artistSummaries.length, + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), }); - const result = await ws.getMetadataAsync({ - id: "artists", - index, - count, + ws.addSoapHeader({ + credentials: someCredentials("someAuthToken"), }); - - 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); + 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("asking for relatedArtists", () => { - describe("when the artist has many", () => { - const relatedArtist1 = anArtist(); - const relatedArtist2 = anArtist(); - const relatedArtist3 = anArtist(); - const relatedArtist4 = anArtist(); + describe("when valid credentials are provided", () => { + let ws: Client; - const artist = anArtist({ - similarArtists: [ - relatedArtist1, - relatedArtist2, - relatedArtist3, - relatedArtist4, - ], + beforeEach(async () => { + musicService.login.mockResolvedValue(musicLibrary); + accessTokens.mint.mockReturnValue(accessToken); + + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); + ws.addSoapHeader({ credentials: someCredentials(authToken) }); }); - 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}`, + describe("asking for the root container", () => { + it("should return it", async () => { + const root = await ws.getMetadataAsync({ + id: "root", index: 0, count: 100, }); - expect(result[0]).toEqual( + expect(root[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 - ), - })), + { + itemType: "container", + id: "artists", + title: "Artists", + }, + { itemType: "albumList", id: "albums", title: "Albums" }, + { + itemType: "playlist", + id: "playlists", + title: "Playlists", + attributes: { + readOnly: "false", + renameable: "false", + userContent: "true", + }, + }, + { 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: 4, + total: 9, }) ); - 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, + describe("asking for the search container", () => { + it("should return it", async () => { + const root = await ws.getMetadataAsync({ + id: "search", + index: 0, + count: 100, }); - expect(result[0]).toEqual( + expect(root[0]).toEqual( getMetadataResult({ - mediaCollection: [relatedArtist2, relatedArtist3].map( - (it) => ({ + 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: "playlist", + id: `playlist:${playlist.id}`, + title: playlist.name, + canPlay: true, + attributes: { + readOnly: "false", + userContent: "false", + renameable: "false", + }, + })), + 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: "playlist", + id: `playlist:${playlist.id}`, + title: playlist.name, + canPlay: true, + attributes: { + readOnly: "false", + userContent: "false", + renameable: "false", + }, + })), + 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( + bonobUrlWithAccessToken, + it + ).href(), + 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( + bonobUrlWithAccessToken, + it + ).href(), + 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, + bonobUrlWithAccessToken, it - ), + ).href(), + })), + 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( + bonobUrlWithAccessToken, + it + ).href(), + })), + 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( + bonobUrlWithAccessToken, + it + ).href(), + })), + index: 0, + total: 4, }) - ), - index: 1, - total: 4, - }) - ); - expect(musicLibrary.artist).toHaveBeenCalledWith(artist.id); + ); + 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( + bonobUrlWithAccessToken, + it + ).href(), + }) + ), + 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( + bonobUrlWithAccessToken, + it + ).href(), + 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( + bonobUrlWithAccessToken, + it + ).href(), + 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( + bonobUrlWithAccessToken, + it + ).href(), + 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( + bonobUrlWithAccessToken, + it + ).href(), + 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( + bonobUrlWithAccessToken, + it + ).href(), + 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( + bonobUrlWithAccessToken, + it + ).href(), + 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( + bonobUrlWithAccessToken, + it + ).href(), + 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( + bonobUrlWithAccessToken, + it + ).href(), + 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( + bonobUrlWithAccessToken, + it + ).href(), + 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(bonobUrlWithAccessToken, 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(bonobUrlWithAccessToken, 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(bonobUrlWithAccessToken, 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(bonobUrlWithAccessToken, 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), + }); + + 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), + }); + + 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; + + beforeEach(async () => { + musicService.login.mockResolvedValue(musicLibrary); + accessTokens.mint.mockReturnValue(accessToken); + + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); + 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(bonobUrlWithAccessToken, 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(bonobUrlWithAccessToken, 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("asking for a track", () => { + it("should return the track", async () => { + const track = aTrack(); + + musicLibrary.track.mockResolvedValue(track); + + const root = await ws.getExtendedMetadataAsync({ + id: `track:${track.id}`, + }); + + expect(root[0]).toEqual({ + getExtendedMetadataResult: { + mediaMetadata: { + id: `track:${track.id}`, + itemType: "track", + title: track.name, + mimeType: track.mimeType, + trackMetadata: { + artistId: `artist:${track.artist.id}`, + artist: track.artist.name, + albumId: `album:${track.album.id}`, + album: track.album.name, + genre: track.genre?.name, + genreId: track.genre?.id, + duration: track.duration, + albumArtURI: defaultAlbumArtURI( + bonobUrlWithAccessToken, + track.album + ).href(), + }, + }, + }, + }); + expect(musicLibrary.track).toHaveBeenCalledWith(track.id); + }); + }); + + describe("asking for an album", () => { + it("should return the album", async () => { + const album = anAlbum(); + + musicLibrary.album.mockResolvedValue(album); + + const root = await ws.getExtendedMetadataAsync({ + id: `album:${album.id}`, + }); + + expect(root[0]).toEqual({ + getExtendedMetadataResult: { + mediaCollection: { + attributes: { + readOnly: "true", + userContent: "false", + renameable: "false", + }, + itemType: "album", + id: `album:${album.id}`, + title: album.name, + albumArtURI: defaultAlbumArtURI( + bonobUrlWithAccessToken, + album + ).href(), + canPlay: true, + artistId: album.artistId, + artist: album.artistName, + }, + }, + }); + expect(musicLibrary.album).toHaveBeenCalledWith(album.id); + }); + }); + }); + }); + + 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), + }); + + 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), + }); + + 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", () => { + 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), + }); + 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: bonobUrl + .append({ + pathname: `/stream/track/${trackId}`, + }) + .href(), + httpHeaders: { + header: "bonob-access-token", + value: accessToken, + }, + }); + + expect(musicService.login).toHaveBeenCalledWith(authToken); expect(accessTokens.mint).toHaveBeenCalledWith(authToken); }); }); }); + }); - describe("when the artist has none", () => { - const artist = anArtist({ similarArtists: [] }); + 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), + }); - beforeEach(() => { - musicLibrary.artist.mockResolvedValue(artist); + 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), + }); + + 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", () => { + 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), + }); + ws.addSoapHeader({ credentials: someCredentials(authToken) }); }); - it("should return an empty list", async () => { - const result = await ws.getMetadataAsync({ - id: `relatedArtists:${artist.id}`, - index: 0, - count: 100, + 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( + bonobUrl.with({ + searchParams: { "bonob-access-token": accessToken }, + }), + someTrack + ), + }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.track).toHaveBeenCalledWith(someTrack.id); }); - expect(result[0]).toEqual( - getMetadataResult({ - index: 0, - total: 0, - }) - ); - expect(musicLibrary.artist).toHaveBeenCalledWith(artist.id); + }); + }); + }); + + describe("createContainer", () => { + 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), + }); + ws.addSoapHeader({ credentials: someCredentials(authToken) }); + }); + + describe("with only a title", () => { + const title = "aNewPlaylist"; + const idOfNewPlaylist = uuid(); + + it("should create a playlist", async () => { + musicLibrary.createPlaylist.mockResolvedValue({ + id: idOfNewPlaylist, + name: title, + }); + + const result = await ws.createContainerAsync({ + title, + }); + + expect(result[0]).toEqual({ + createContainerResult: { + id: `playlist:${idOfNewPlaylist}`, + updateId: null, + }, + }); + expect(musicService.login).toHaveBeenCalledWith(authToken); 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, - }); + expect(musicLibrary.createPlaylist).toHaveBeenCalledWith(title); }); }); - describe("asking for starred albums", () => { - const albums = [rock2, rock1, pop2]; + describe("with a title and a seed track", () => { + const title = "aNewPlaylist2"; + const trackId = "track123"; + const idOfNewPlaylist = "playlistId"; - beforeEach(() => { - musicLibrary.albums.mockResolvedValue({ - results: albums, - total: allAlbums.length, + it("should create a playlist with the track", async () => { + musicLibrary.createPlaylist.mockResolvedValue({ + id: idOfNewPlaylist, + name: title, }); - }); + musicLibrary.addToPlaylist.mockResolvedValue(true); - it("should return some", async () => { - const paging = { - index: 0, - count: 100, - }; - - const result = await ws.getMetadataAsync({ - id: "starredAlbums", - ...paging, + const result = await ws.createContainerAsync({ + title, + seedId: `track:${trackId}`, }); - 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", + expect(result[0]).toEqual({ + createContainerResult: { + id: `playlist:${idOfNewPlaylist}`, + updateId: null, }, }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.createPlaylist).toHaveBeenCalledWith(title); + expect(musicLibrary.addToPlaylist).toHaveBeenCalledWith( + idOfNewPlaylist, + trackId + ); }); }); }); - describe("asking for a track", () => { - it("should return the track", async () => { - const track = aTrack(); + describe("deleteContainer", () => { + const id = "id123"; - musicLibrary.track.mockResolvedValue(track); + let ws: Client; - const root = await ws.getExtendedMetadataAsync({ - id: `track:${track.id}`, + beforeEach(async () => { + musicService.login.mockResolvedValue(musicLibrary); + accessTokens.mint.mockReturnValue(accessToken); + + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); + ws.addSoapHeader({ credentials: someCredentials(authToken) }); + }); + + it("should delete the playlist", async () => { + musicLibrary.deletePlaylist.mockResolvedValue(true); + + const result = await ws.deleteContainerAsync({ + id, }); - expect(root[0]).toEqual({ - getExtendedMetadataResult: { - mediaMetadata: { - id: `track:${track.id}`, - itemType: "track", - title: track.name, - mimeType: track.mimeType, - trackMetadata: { - artistId: `artist:${track.artist.id}`, - artist: track.artist.name, - albumId: `album:${track.album.id}`, - album: track.album.name, - genre: track.genre?.name, - genreId: track.genre?.id, - duration: track.duration, - albumArtURI: defaultAlbumArtURI( - rootUrl, - accessToken, - track.album - ), - }, - }, - }, - }); - expect(musicLibrary.track).toHaveBeenCalledWith(track.id); + expect(result[0]).toEqual({ deleteContainerResult: null }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.deletePlaylist).toHaveBeenCalledWith(id); }); }); - describe("asking for an album", () => { - it("should return the album", async () => { - const album = anAlbum(); + describe("addToContainer", () => { + const trackId = "track123"; + const playlistId = "parent123"; - musicLibrary.album.mockResolvedValue(album); + let ws: Client; - const root = await ws.getExtendedMetadataAsync({ - id: `album:${album.id}`, + beforeEach(async () => { + musicService.login.mockResolvedValue(musicLibrary); + accessTokens.mint.mockReturnValue(accessToken); + + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), }); - - expect(root[0]).toEqual({ - getExtendedMetadataResult: { - mediaCollection: { - attributes: { - readOnly: "true", - userContent: "false", - renameable: "false", - }, - itemType: "album", - id: `album:${album.id}`, - title: album.name, - albumArtURI: defaultAlbumArtURI(rootUrl, accessToken, album), - canPlay: true, - artistId: album.artistId, - artist: album.artistName, - }, - }, - }); - expect(musicLibrary.album).toHaveBeenCalledWith(album.id); - }); - }); - }); - }); - - 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), + ws.addSoapHeader({ credentials: someCredentials(authToken) }); }); - 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...", - }); - }); - }); - }); + it("should delete the playlist", async () => { + musicLibrary.addToPlaylist.mockResolvedValue(true); - 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({ + const result = await ws.addToContainerAsync({ id: `track:${trackId}`, + parentId: `parent:${playlistId}`, }); - 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(result[0]).toEqual({ + addToContainerResult: { updateId: null }, }); expect(musicService.login).toHaveBeenCalledWith(authToken); expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.track).toHaveBeenCalledWith(someTrack.id); - }); - }); - }); - }); - - describe("createContainer", () => { - 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("with only a title", () => { - const title = "aNewPlaylist"; - const idOfNewPlaylist = uuid(); - - it("should create a playlist", async () => { - musicLibrary.createPlaylist.mockResolvedValue({ - id: idOfNewPlaylist, - name: title, - }); - - const result = await ws.createContainerAsync({ - title, - }); - - expect(result[0]).toEqual({ - createContainerResult: { - id: `playlist:${idOfNewPlaylist}`, - updateId: null, - }, - }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.createPlaylist).toHaveBeenCalledWith(title); - }); - }); - - describe("with a title and a seed track", () => { - const title = "aNewPlaylist2"; - const trackId = "track123"; - const idOfNewPlaylist = "playlistId"; - - it("should create a playlist with the track", async () => { - musicLibrary.createPlaylist.mockResolvedValue({ - id: idOfNewPlaylist, - name: title, - }); - musicLibrary.addToPlaylist.mockResolvedValue(true); - - const result = await ws.createContainerAsync({ - title, - seedId: `track:${trackId}`, - }); - - expect(result[0]).toEqual({ - createContainerResult: { - id: `playlist:${idOfNewPlaylist}`, - updateId: null, - }, - }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.createPlaylist).toHaveBeenCalledWith(title); - expect(musicLibrary.addToPlaylist).toHaveBeenCalledWith( - idOfNewPlaylist, - trackId - ); - }); - }); - }); - - describe("deleteContainer", () => { - const authToken = `authToken-${uuid()}`; - const accessToken = `accessToken-${uuid()}`; - const id = "id123"; - - 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) }); - }); - - it("should delete the playlist", async () => { - musicLibrary.deletePlaylist.mockResolvedValue(true); - - const result = await ws.deleteContainerAsync({ - id, - }); - - expect(result[0]).toEqual({ deleteContainerResult: null }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.deletePlaylist).toHaveBeenCalledWith(id); - }); - }); - - describe("addToContainer", () => { - const authToken = `authToken-${uuid()}`; - const accessToken = `accessToken-${uuid()}`; - const trackId = "track123"; - const playlistId = "parent123"; - - 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) }); - }); - - it("should delete the playlist", async () => { - musicLibrary.addToPlaylist.mockResolvedValue(true); - - const result = await ws.addToContainerAsync({ - id: `track:${trackId}`, - parentId: `parent:${playlistId}`, - }); - - expect(result[0]).toEqual({ addToContainerResult: { updateId: null } }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.addToPlaylist).toHaveBeenCalledWith( - playlistId, - trackId - ); - }); - }); - - describe("removeFromContainer", () => { - 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("removing tracks from a playlist", () => { - const playlistId = "parent123"; - - it("should remove the track from playlist", async () => { - musicLibrary.removeFromPlaylist.mockResolvedValue(true); - - const result = await ws.removeFromContainerAsync({ - id: `playlist:${playlistId}`, - indices: `1,6,9`, - }); - - expect(result[0]).toEqual({ - removeFromContainerResult: { updateId: null }, - }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.removeFromPlaylist).toHaveBeenCalledWith( - playlistId, - [1, 6, 9] - ); - }); - }); - - describe("removing a playlist", () => { - const playlist1 = aPlaylist({ id: "p1" }); - const playlist2 = aPlaylist({ id: "p2" }); - const playlist3 = aPlaylist({ id: "p3" }); - const playlist4 = aPlaylist({ id: "p4" }); - const playlist5 = aPlaylist({ id: "p5" }); - - it("should delete the playlist", async () => { - musicLibrary.playlists.mockResolvedValue([ - playlist1, - playlist2, - playlist3, - playlist4, - playlist5, - ]); - musicLibrary.deletePlaylist.mockResolvedValue(true); - - const result = await ws.removeFromContainerAsync({ - id: `playlists`, - indices: `0,2,4`, - }); - - expect(result[0]).toEqual({ - removeFromContainerResult: { updateId: null }, - }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.deletePlaylist).toHaveBeenCalledTimes(3); - expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith( - 1, - playlist1.id - ); - expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith( - 2, - playlist3.id - ); - expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith( - 3, - playlist5.id - ); - }); - }); - }); - - describe("setPlayedSeconds", () => { - 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("when id is for a track", () => { - const trackId = "123456"; - - function itShouldScroble({ - trackId, - secondsPlayed, - }: { - trackId: string; - secondsPlayed: number; - }) { - it("should scrobble", async () => { - musicLibrary.scrobble.mockResolvedValue(true); - - const result = await ws.setPlayedSecondsAsync({ - id: `track:${trackId}`, - seconds: `${secondsPlayed}`, - }); - - expect(result[0]).toEqual({ setPlayedSecondsResult: null }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.track).toHaveBeenCalledWith(trackId); - expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId); - }); - } - - function itShouldNotScroble({ - trackId, - secondsPlayed, - }: { - trackId: string; - secondsPlayed: number; - }) { - it("should scrobble", async () => { - const result = await ws.setPlayedSecondsAsync({ - id: `track:${trackId}`, - seconds: `${secondsPlayed}`, - }); - - expect(result[0]).toEqual({ setPlayedSecondsResult: null }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.track).toHaveBeenCalledWith(trackId); - expect(musicLibrary.scrobble).not.toHaveBeenCalled(); - }); - } - - describe("when the track length is 30 seconds", () => { - beforeEach(() => { - musicLibrary.track.mockResolvedValue( - aTrack({ id: trackId, duration: 30 }) + expect(musicLibrary.addToPlaylist).toHaveBeenCalledWith( + playlistId, + trackId ); }); + }); - describe("when the played length is 30 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 30 }); + describe("removeFromContainer", () => { + 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), + }); + ws.addSoapHeader({ credentials: someCredentials(authToken) }); }); - describe("when the played length is > 30 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 90 }); + describe("removing tracks from a playlist", () => { + const playlistId = "parent123"; + + it("should remove the track from playlist", async () => { + musicLibrary.removeFromPlaylist.mockResolvedValue(true); + + const result = await ws.removeFromContainerAsync({ + id: `playlist:${playlistId}`, + indices: `1,6,9`, + }); + + expect(result[0]).toEqual({ + removeFromContainerResult: { updateId: null }, + }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.removeFromPlaylist).toHaveBeenCalledWith( + playlistId, + [1, 6, 9] + ); + }); }); - describe("when the played length is < 30 seconds", () => { - itShouldNotScroble({ trackId, secondsPlayed: 29 }); + describe("removing a playlist", () => { + const playlist1 = aPlaylist({ id: "p1" }); + const playlist2 = aPlaylist({ id: "p2" }); + const playlist3 = aPlaylist({ id: "p3" }); + const playlist4 = aPlaylist({ id: "p4" }); + const playlist5 = aPlaylist({ id: "p5" }); + + it("should delete the playlist", async () => { + musicLibrary.playlists.mockResolvedValue([ + playlist1, + playlist2, + playlist3, + playlist4, + playlist5, + ]); + musicLibrary.deletePlaylist.mockResolvedValue(true); + + const result = await ws.removeFromContainerAsync({ + id: `playlists`, + indices: `0,2,4`, + }); + + expect(result[0]).toEqual({ + removeFromContainerResult: { updateId: null }, + }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.deletePlaylist).toHaveBeenCalledTimes(3); + expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith( + 1, + playlist1.id + ); + expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith( + 2, + playlist3.id + ); + expect(musicLibrary.deletePlaylist).toHaveBeenNthCalledWith( + 3, + playlist5.id + ); + }); }); }); - describe("when the track length is > 30 seconds", () => { - beforeEach(() => { - musicLibrary.track.mockResolvedValue( - aTrack({ id: trackId, duration: 31 }) - ); + describe("setPlayedSeconds", () => { + 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), + }); + ws.addSoapHeader({ credentials: someCredentials(authToken) }); }); - describe("when the played length is 30 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 30 }); + describe("when id is for a track", () => { + const trackId = "123456"; + + function itShouldScroble({ + trackId, + secondsPlayed, + }: { + trackId: string; + secondsPlayed: number; + }) { + it("should scrobble", async () => { + musicLibrary.scrobble.mockResolvedValue(true); + + const result = await ws.setPlayedSecondsAsync({ + id: `track:${trackId}`, + seconds: `${secondsPlayed}`, + }); + + expect(result[0]).toEqual({ setPlayedSecondsResult: null }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.track).toHaveBeenCalledWith(trackId); + expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId); + }); + } + + function itShouldNotScroble({ + trackId, + secondsPlayed, + }: { + trackId: string; + secondsPlayed: number; + }) { + it("should scrobble", async () => { + const result = await ws.setPlayedSecondsAsync({ + id: `track:${trackId}`, + seconds: `${secondsPlayed}`, + }); + + expect(result[0]).toEqual({ setPlayedSecondsResult: null }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.track).toHaveBeenCalledWith(trackId); + expect(musicLibrary.scrobble).not.toHaveBeenCalled(); + }); + } + + describe("when the track length is 30 seconds", () => { + beforeEach(() => { + musicLibrary.track.mockResolvedValue( + aTrack({ id: trackId, duration: 30 }) + ); + }); + + describe("when the played length is 30 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 30 }); + }); + + describe("when the played length is > 30 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 90 }); + }); + + describe("when the played length is < 30 seconds", () => { + itShouldNotScroble({ trackId, secondsPlayed: 29 }); + }); + }); + + describe("when the track length is > 30 seconds", () => { + beforeEach(() => { + musicLibrary.track.mockResolvedValue( + aTrack({ id: trackId, duration: 31 }) + ); + }); + + describe("when the played length is 30 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 30 }); + }); + + describe("when the played length is > 30 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 90 }); + }); + + describe("when the played length is < 30 seconds", () => { + itShouldNotScroble({ trackId, secondsPlayed: 29 }); + }); + }); + + describe("when the track length is 29 seconds", () => { + beforeEach(() => { + musicLibrary.track.mockResolvedValue( + aTrack({ id: trackId, duration: 29 }) + ); + }); + + describe("when the played length is 29 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 30 }); + }); + + describe("when the played length is > 29 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 30 }); + }); + + describe("when the played length is 10 seconds", () => { + itShouldScroble({ trackId, secondsPlayed: 10 }); + }); + + describe("when the played length is < 10 seconds", () => { + itShouldNotScroble({ trackId, secondsPlayed: 9 }); + }); + }); }); - describe("when the played length is > 30 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 90 }); - }); + describe("when the id is for something that isnt a track", () => { + it("should not scrobble", async () => { + const result = await ws.setPlayedSecondsAsync({ + id: `album:666`, + seconds: "100", + }); - describe("when the played length is < 30 seconds", () => { - itShouldNotScroble({ trackId, secondsPlayed: 29 }); + expect(result[0]).toEqual({ setPlayedSecondsResult: null }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.scrobble).not.toHaveBeenCalled(); + }); }); }); - - describe("when the track length is 29 seconds", () => { - beforeEach(() => { - musicLibrary.track.mockResolvedValue( - aTrack({ id: trackId, duration: 29 }) - ); - }); - - describe("when the played length is 29 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 30 }); - }); - - describe("when the played length is > 29 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 30 }); - }); - - describe("when the played length is 10 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 10 }); - }); - - describe("when the played length is < 10 seconds", () => { - itShouldNotScroble({ trackId, secondsPlayed: 9 }); - }); - }); - }); - - describe("when the id is for something that isnt a track", () => { - it("should not scrobble", async () => { - const result = await ws.setPlayedSecondsAsync({ - id: `album:666`, - seconds: "100", - }); - - expect(result[0]).toEqual({ setPlayedSecondsResult: null }); - expect(musicService.login).toHaveBeenCalledWith(authToken); - expect(accessTokens.mint).toHaveBeenCalledWith(authToken); - expect(musicLibrary.scrobble).not.toHaveBeenCalled(); - }); }); }); }); diff --git a/tests/sonos.test.ts b/tests/sonos.test.ts index 9b52150..4aee547 100644 --- a/tests/sonos.test.ts +++ b/tests/sonos.test.ts @@ -25,6 +25,7 @@ import sonos, { } from "../src/sonos"; import { aSonosDevice, aService } from "./builders"; +import url from "../src/url_builder"; const mockSonosManagerConstructor = >SonosManager; @@ -107,10 +108,10 @@ describe("sonos", () => { }); describe("bonobService", () => { - describe("when the bonob root does not have a trailing /", () => { + describe("when the bonob url does not have a trailing /", () => { it("should return a valid bonob service", () => { expect( - bonobService("some-bonob", 876, "http://bonob.example.com") + bonobService("some-bonob", 876, url("http://bonob.example.com")) ).toEqual({ name: "some-bonob", sid: 876, @@ -130,10 +131,10 @@ describe("sonos", () => { }); }); - describe("when the bonob root does have a trailing /", () => { + describe("when the bonob url does have a trailing /", () => { it("should return a valid bonob service", () => { expect( - bonobService("some-bonob", 876, "http://bonob.example.com/") + bonobService("some-bonob", 876, url("http://bonob.example.com/")) ).toEqual({ name: "some-bonob", sid: 876, @@ -153,10 +154,33 @@ describe("sonos", () => { }); }); + describe("when the bonob url has a context of /some-context", () => { + it("should return a valid bonob service", () => { + expect( + bonobService("some-bonob", 876, url("http://bonob.example.com/some-context")) + ).toEqual({ + name: "some-bonob", + sid: 876, + uri: `http://bonob.example.com/some-context/ws/sonos`, + secureUri: `http://bonob.example.com/some-context/ws/sonos`, + strings: { + uri: `http://bonob.example.com/some-context/sonos/strings.xml`, + version: PRESENTATION_AND_STRINGS_VERSION, + }, + presentation: { + uri: `http://bonob.example.com/some-context/sonos/presentationMap.xml`, + version: PRESENTATION_AND_STRINGS_VERSION, + }, + pollInterval: 1200, + authType: "AppLink", + }); + }); + }); + describe("when authType is specified", () => { it("should return a valid bonob service", () => { expect( - bonobService("some-bonob", 876, "http://bonob.example.com", 'DeviceLink') + bonobService("some-bonob", 876, url("http://bonob.example.com"), 'DeviceLink') ).toEqual({ name: "some-bonob", sid: 876, @@ -242,7 +266,7 @@ describe("sonos", () => { expect(disabled).toEqual(SONOS_DISABLED); expect(await disabled.devices()).toEqual([]); expect(await disabled.services()).toEqual([]); - expect(await disabled.register(aService())).toEqual(false); + expect(await disabled.register(aService())).toEqual(true); }); }); diff --git a/tests/supersoap.ts b/tests/supersoap.ts index 6b4cf3c..ed1078d 100644 --- a/tests/supersoap.ts +++ b/tests/supersoap.ts @@ -1,7 +1,7 @@ import { Express } from "express"; import request from "supertest"; -function supersoap(server: Express, rootUrl: string) { +function supersoap(server: Express) { return { request: ( rurl: string, @@ -9,7 +9,8 @@ function supersoap(server: Express, rootUrl: string) { callback: (error: any, res?: any, body?: any) => any, exheaders?: any ) => { - const withoutHost = rurl.replace(rootUrl, ""); + const url = new URL(rurl); + const withoutHost = `${url.pathname}${url.search}`; const req = data == null ? request(server).get(withoutHost).send() diff --git a/tests/url_builder.test.ts b/tests/url_builder.test.ts new file mode 100644 index 0000000..aaaffc8 --- /dev/null +++ b/tests/url_builder.test.ts @@ -0,0 +1,212 @@ +import url from "../src/url_builder"; + +describe("URLBuilder", () => { + describe("construction", () => { + it("with a string", () => { + expect(url("http://example.com/").href()).toEqual("http://example.com/"); + expect(url("http://example.com/foobar?name=bob").href()).toEqual( + "http://example.com/foobar?name=bob" + ); + }); + + it("with a URL", () => { + expect(url(new URL("http://example.com/")).href()).toEqual( + "http://example.com/" + ); + expect(url(new URL("http://example.com/foobar?name=bob")).href()).toEqual( + "http://example.com/foobar?name=bob" + ); + }); + }); + + describe("toString", () => { + it("should print the href", () => { + expect(`${url("http://example.com/")}`).toEqual("http://example.com/"); + expect(`${url("http://example.com/foobar?name=bob")}`).toEqual( + "http://example.com/foobar?name=bob" + ); + }); + }); + + describe("path", () => { + it("should be the pathname and search", () => { + expect(url("http://example.com/").path()).toEqual("/"); + expect(url("http://example.com/?whoop=ie").path()).toEqual("/?whoop=ie"); + expect(url("http://example.com/foo/bar").path()).toEqual("/foo/bar"); + expect(url("http://example.com/with/search?q=bob&s=100").path()).toEqual("/with/search?q=bob&s=100"); + expect(url("http://example.com/drops/hash#1234").path()).toEqual("/drops/hash"); + }); + }); + + describe("updating the pathname", () => { + describe("appending", () => { + describe("when there is no existing pathname", ()=>{ + it("should return a new URLBuilder with the new pathname appended to the existing pathname", () => { + const original = url("https://example.com?a=b"); + const updated = original.append({ pathname: "/the-appended-path" }); + + expect(original.href()).toEqual("https://example.com/?a=b"); + expect(original.pathname()).toEqual("/") + + expect(updated.href()).toEqual("https://example.com/the-appended-path?a=b"); + expect(updated.pathname()).toEqual("/the-appended-path") + }); + }); + + describe("when the existing pathname is /", ()=>{ + it("should return a new URLBuilder with the new pathname appended to the existing pathname", () => { + const original = url("https://example.com/"); + const updated = original.append({ pathname: "/the-appended-path" }); + + expect(original.href()).toEqual("https://example.com/"); + expect(original.pathname()).toEqual("/") + + expect(updated.href()).toEqual("https://example.com/the-appended-path"); + expect(updated.pathname()).toEqual("/the-appended-path") + }); + }); + + describe("when the existing pathname is /first-path", ()=>{ + it("should return a new URLBuilder with the new pathname appended to the existing pathname", () => { + const original = url("https://example.com/first-path"); + const updated = original.append({ pathname: "/the-appended-path" }); + + expect(original.href()).toEqual("https://example.com/first-path"); + expect(original.pathname()).toEqual("/first-path") + + expect(updated.href()).toEqual("https://example.com/first-path/the-appended-path"); + expect(updated.pathname()).toEqual("/first-path/the-appended-path") + }); + }); + + describe("when the existing pathname is /first-path/", ()=>{ + it("should return a new URLBuilder with the new pathname appended to the existing pathname", () => { + const original = url("https://example.com/first-path/"); + const updated = original.append({ pathname: "/the-appended-path" }); + + expect(original.href()).toEqual("https://example.com/first-path/"); + expect(original.pathname()).toEqual("/first-path/") + + expect(updated.href()).toEqual("https://example.com/first-path/the-appended-path"); + expect(updated.pathname()).toEqual("/first-path/the-appended-path") + }); + }); + + it("should return a new URLBuilder with the new pathname appended to the existing pathname", () => { + const original = url("https://example.com/some-path?a=b"); + const updated = original.append({ pathname: "/some-new-path" }); + + expect(original.href()).toEqual("https://example.com/some-path?a=b"); + expect(original.pathname()).toEqual("/some-path") + + expect(updated.href()).toEqual("https://example.com/some-path/some-new-path?a=b"); + expect(updated.pathname()).toEqual("/some-path/some-new-path") + }); + }); + + describe("replacing", () => { + it("should return a new URLBuilder with the new pathname", () => { + const original = url("https://example.com/some-path?a=b"); + const updated = original.with({ pathname: "/some-new-path" }); + + expect(original.href()).toEqual("https://example.com/some-path?a=b"); + expect(original.pathname()).toEqual("/some-path") + + expect(updated.href()).toEqual("https://example.com/some-new-path?a=b"); + expect(updated.pathname()).toEqual("/some-new-path") + }); + }); + }); + + describe("updating search params", () => { + describe("appending", () => { + describe("with records", () => { + it("should return a new URLBuilder with the new search params appended", () => { + const original = url("https://example.com/some-path?a=b&c=d"); + const updated = original.append({ + searchParams: { x: "y", z: "1" }, + }); + + expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d"); + expect(`${original.searchParams()}`).toEqual("a=b&c=d") + + expect(updated.href()).toEqual("https://example.com/some-path?a=b&c=d&x=y&z=1"); + expect(`${updated.searchParams()}`).toEqual("a=b&c=d&x=y&z=1") + }); + }); + + describe("with URLSearchParams", () => { + it("should return a new URLBuilder with the new search params appended", () => { + const original = url("https://example.com/some-path?a=b&c=d"); + const updated = original.append({ + searchParams: new URLSearchParams({ x: "y", z: "1" }), + }); + + expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d"); + expect(`${original.searchParams()}`).toEqual("a=b&c=d") + + expect(updated.href()).toEqual("https://example.com/some-path?a=b&c=d&x=y&z=1"); + expect(`${updated.searchParams()}`).toEqual("a=b&c=d&x=y&z=1") + }); + }); + }); + + describe("replacing", () => { + describe("with records", () => { + it("should be able to remove all search params", () => { + const original = url("https://example.com/some-path?a=b&c=d"); + const updated = original.with({ + searchParams: {}, + }); + + expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d"); + expect(`${original.searchParams()}`).toEqual("a=b&c=d") + + expect(updated.href()).toEqual("https://example.com/some-path"); + expect(`${updated.searchParams()}`).toEqual("") + }); + + it("should return a new URLBuilder with the new search params", () => { + const original = url("https://example.com/some-path?a=b&c=d"); + const updated = original.with({ + searchParams: { x: "y", z: "1" }, + }); + + expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d"); + expect(`${original.searchParams()}`).toEqual("a=b&c=d") + + expect(updated.href()).toEqual("https://example.com/some-path?x=y&z=1"); + expect(`${updated.searchParams()}`).toEqual("x=y&z=1") + }); + }); + + describe("with URLSearchParams", () => { + it("should be able to remove all search params", () => { + const original = url("https://example.com/some-path?a=b&c=d"); + const updated = original.with({ + searchParams: new URLSearchParams({}), + }); + + expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d"); + expect(`${original.searchParams()}`).toEqual("a=b&c=d") + + expect(updated.href()).toEqual("https://example.com/some-path"); + expect(`${updated.searchParams()}`).toEqual("") + }); + + it("should return a new URLBuilder with the new search params", () => { + const original = url("https://example.com/some-path?a=b&c=d"); + const updated = original.with({ + searchParams: new URLSearchParams({ x: "y", z: "1" }), + }); + + expect(original.href()).toEqual("https://example.com/some-path?a=b&c=d"); + expect(`${original.searchParams()}`).toEqual("a=b&c=d") + + expect(updated.href()).toEqual("https://example.com/some-path?x=y&z=1"); + expect(`${updated.searchParams()}`).toEqual("x=y&z=1") + }); + }); + }); + }); +}); diff --git a/web/views/index.eta b/web/views/index.eta index 7271d06..54183bc 100644 --- a/web/views/index.eta +++ b/web/views/index.eta @@ -10,7 +10,7 @@ <% } else { %>

No existing service registration

<% } %> -
+

Devices

    <% it.devices.forEach(function(d){ %>