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){ %>