From 3b350c4402462d64a849e2627f2aa6447c88dfaa Mon Sep 17 00:00:00 2001 From: simojenki Date: Mon, 1 Mar 2021 12:46:23 +1100 Subject: [PATCH] Tests for browsing of artists and albums --- package.json | 1 + src/link_codes.ts | 2 +- src/music_service.ts | 68 +++++++-- src/server.ts | 4 +- src/smapi.ts | 138 +++++++++++++++++- tests/builders.ts | 38 +++++ tests/in_memory_music_service.test.ts | 159 ++++++++++++++++++++ tests/in_memory_music_service.ts | 85 ++++++++++- tests/scenarios.test.ts | 131 ++++++++++++++--- tests/smapi.test.ts | 202 ++++++++++++++++++++++++-- tests/tsconfig.json | 2 +- yarn.lock | 5 + 12 files changed, 778 insertions(+), 57 deletions(-) create mode 100644 tests/in_memory_music_service.test.ts diff --git a/package.json b/package.json index ffb93ff..0dd27fd 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "axios": "^0.21.1", "eta": "^1.12.1", "express": "^4.17.1", + "fp-ts": "^2.9.5", "morgan": "^1.10.0", "node-html-parser": "^2.1.0", "soap": "^0.36.0", diff --git a/src/link_codes.ts b/src/link_codes.ts index 023baa0..45e017d 100644 --- a/src/link_codes.ts +++ b/src/link_codes.ts @@ -10,7 +10,7 @@ export type Association = { export interface LinkCodes { mint(): string clear(): any - count(): Number + count(): number has(linkCode: string): boolean associate(linkCode: string, association: Association): any associationFor(linkCode: string): Association | undefined diff --git a/src/music_service.ts b/src/music_service.ts index 2411561..5501960 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -6,29 +6,72 @@ const navidrome = process.env["BONOB_NAVIDROME_URL"]; const u = process.env["BONOB_USER"]; const t = Md5.hashStr(`${process.env["BONOB_PASSWORD"]}${s}`); -export type Credentials = { username: string, password: string } +export type Credentials = { username: string; password: string }; -export function isSuccess(authResult: AuthSuccess | AuthFailure): authResult is AuthSuccess { +export function isSuccess( + authResult: AuthSuccess | AuthFailure +): authResult is AuthSuccess { return (authResult as AuthSuccess).authToken !== undefined; } -export type AuthSuccess = { - authToken: string - userId: string - nickname: string +export function isFailure( + authResult: any | AuthFailure +): authResult is AuthFailure { + return (authResult as AuthFailure).message !== undefined; } +export type AuthSuccess = { + authToken: string; + userId: string; + nickname: string; +}; + export type AuthFailure = { - message: string -} + message: string; +}; export interface MusicService { - login(credentials: Credentials): AuthSuccess | AuthFailure + generateToken(credentials: Credentials): AuthSuccess | AuthFailure; + login(authToken: string): MusicLibrary | AuthFailure; +} + +export type Artist = { + id: string; + name: string; +}; + +export type Album = { + id: string; + name: string; +}; + +export interface MusicLibrary { + artists(): Artist[]; + artist(id: string): Artist; + albums({ artistId, _index, _count }: { artistId?: string, _index?: number, _count?: number }): Album[]; } export class Navidrome implements MusicService { - login({ username }: Credentials) { - return { authToken: `v1:${username}`, userId: username, nickname: username } + generateToken({ username }: Credentials) { + return { + authToken: `v1:${username}`, + userId: username, + nickname: username, + }; + } + + login(_: string) { + return { + artists: () => [], + artist: (id: string) => ({ + id, + name: id, + }), + albums: ({ artistId }: { artistId?: string }) => { + console.log(artistId) + return [] + }, + }; } ping = (): Promise => @@ -42,6 +85,3 @@ export class Navidrome implements MusicService { return false; }); } - - - diff --git a/src/server.ts b/src/server.ts index 4562bf1..754336e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -71,7 +71,7 @@ function server( app.post(LOGIN_ROUTE, (req, res) => { const { username, password, linkCode } = req.body; - const authResult = musicService.login({ + const authResult = musicService.generateToken({ username, password, }); @@ -107,7 +107,7 @@ function server( res.send(""); }); - bindSmapiSoapServiceToExpress(app, SOAP_PATH, webAddress, linkCodes); + bindSmapiSoapServiceToExpress(app, SOAP_PATH, webAddress, linkCodes, musicService); return app; } diff --git a/src/smapi.ts b/src/smapi.ts index 89c8348..701d44d 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -6,8 +6,9 @@ import path from "path"; import logger from "./logger"; import { LinkCodes } from "./link_codes"; +import { isFailure, MusicLibrary, MusicService } from "./music_service"; -export const LOGIN_ROUTE = "/login" +export const LOGIN_ROUTE = "/login"; export const SOAP_PATH = "/ws/sonos"; export const STRINGS_ROUTE = "/sonos/strings.xml"; export const PRESENTATION_MAP_ROUTE = "/sonos/presentationMap.xml"; @@ -17,6 +18,15 @@ const WSDL_FILE = path.resolve( "Sonoswsdl-1.19.4-20190411.142401-3.wsdl" ); +export type Credentials = { + loginToken: { + token: string; + householdId: string; + }; + deviceId: string; + deviceProvider: string; +}; + export type GetAppLinkResult = { getAppLinkResult: { authorizeAccount: { @@ -37,6 +47,36 @@ export type GetDeviceAuthTokenResult = { }; }; +export type MediaCollection = { + id: string; + itemType: "collection"; + title: string; +}; + +export type GetMetadataResponse = { + getMetadataResult: { + count: number; + index: number; + total: number; + mediaCollection: MediaCollection[]; + }; +}; + +export function getMetadataResult({ + mediaCollection, +}: { + mediaCollection: any[] | undefined; +}): GetMetadataResponse { + return { + getMetadataResult: { + count: mediaCollection?.length || 0, + index: 0, + total: mediaCollection?.length || 0, + mediaCollection: mediaCollection || [], + }, + }; +} + class SonosSoap { linkCodes: LinkCodes; webAddress: string; @@ -97,7 +137,35 @@ class SonosSoap { } } -function bindSmapiSoapServiceToExpress(app: Express, soapPath:string, webAddress: string, linkCodes: LinkCodes) { +export type Container = { + itemType: "container"; + id: string; + title: string; +}; + +export const container = ({ + id, + title, +}: { + id: string; + title: string; +}): Container => ({ + itemType: "container", + id, + title, +}); + +type SoapyHeaders = { + credentials?: Credentials; +}; + +function bindSmapiSoapServiceToExpress( + app: Express, + soapPath: string, + webAddress: string, + linkCodes: LinkCodes, + musicService: MusicService +) { const sonosSoap = new SonosSoap(webAddress, linkCodes); const soapyService = listen( app, @@ -108,6 +176,72 @@ function bindSmapiSoapServiceToExpress(app: Express, soapPath:string, webAddress getAppLink: () => sonosSoap.getAppLink(), getDeviceAuthToken: ({ linkCode }: { linkCode: string }) => sonosSoap.getDeviceAuthToken({ linkCode }), + getMetadata: ( + { + id, + index, + count, + // recursive, + }: { id: string; index: number; count: number; recursive: boolean }, + _, + headers?: SoapyHeaders + ) => { + if (!headers?.credentials) { + throw { + Fault: { + faultcode: "Client.LoginUnsupported", + faultstring: "Missing credentials...", + }, + }; + } + const login = musicService.login( + headers.credentials.loginToken.token + ); + if (isFailure(login)) { + throw { + Fault: { + faultcode: "Client.LoginUnauthorized", + faultstring: "Credentials not found...", + }, + }; + } + + const musicLibrary = login as MusicLibrary; + + // const [type, typeId] = id.split(":"); + const type = id; + switch (type) { + case "root": + return getMetadataResult({ + mediaCollection: [ + container({ id: "artists", title: "Artists" }), + container({ id: "albums", title: "Albums" }), + ], + }); + case "artists": + return getMetadataResult({ + mediaCollection: musicLibrary.artists().map((it) => + container({ + id: `artist:${it.id}`, + title: it.name, + }) + ), + }); + case "albums": + return getMetadataResult({ + mediaCollection: musicLibrary + .albums({ _index: index, _count: count }) + .map((it) => + container({ + id: `album:${it.id}`, + title: it.name, + }) + ), + }); + default: + throw `Unsupported id:${id}`; + } + }, }, }, }, diff --git a/tests/builders.ts b/tests/builders.ts index eaa7c0c..3787143 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -1,5 +1,7 @@ import { SonosDevice } from "@svrooij/sonos/lib"; +import { ArtistWithAlbums } from "in_memory_music_service"; import { v4 as uuid } from "uuid"; +import { Credentials } from "../src/smapi"; import { Service, Device } from "../src/sonos"; @@ -54,3 +56,39 @@ export function getAppLinkMessage() { callbackPath: "", }; } + +export function someCredentials(token: string): Credentials { + return { + loginToken: { + token, + householdId: "hh1" + }, + deviceId: "d1", + deviceProvider: "dp1" + } +} + +export const BOB_MARLEY: ArtistWithAlbums = { + id: uuid(), + name: "Bob Marley", + albums: [ + { id: uuid(), name: "Burin'" }, + { id: uuid(), name: "Exodus" }, + { id: uuid(), name: "Kaya" }, + ], +}; + +export const BLONDIE: ArtistWithAlbums = { + id: uuid(), + name: "Blondie", + albums: [ + { id: uuid(), name: "Blondie" }, + { id: uuid(), name: "Parallel Lines" }, + ], +}; + +export const MADONNA: ArtistWithAlbums = { + id: uuid(), + name: "Madonna", + albums: [], +}; diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts new file mode 100644 index 0000000..da4a921 --- /dev/null +++ b/tests/in_memory_music_service.test.ts @@ -0,0 +1,159 @@ +import { + InMemoryMusicService, +} from "./in_memory_music_service"; +import { AuthSuccess, MusicLibrary } from "../src/music_service"; +import { v4 as uuid } from "uuid"; +import { BOB_MARLEY, MADONNA, BLONDIE } from './builders' + +describe("InMemoryMusicService", () => { + const service = new InMemoryMusicService(); + + describe("generateToken", () => { + it("should be able to generate a token and then use it to log in", () => { + const credentials = { username: "bob", password: "smith" }; + + service.hasUser(credentials); + + const token = service.generateToken(credentials) as AuthSuccess; + + expect(token.userId).toEqual(credentials.username); + expect(token.nickname).toEqual(credentials.username); + + const musicLibrary = service.login(token.authToken); + + expect(musicLibrary).toBeDefined(); + }); + + it("should fail with an exception if an invalid token is used", () => { + const credentials = { username: "bob", password: "smith" }; + + service.hasUser(credentials); + + const token = service.generateToken(credentials) as AuthSuccess; + + service.clear(); + + expect(service.login(token.authToken)).toEqual({ + message: "Invalid auth token", + }); + }); + }); + + describe("Music Library", () => { + + + const user = { username: "user100", password: "password100" }; + let musicLibrary: MusicLibrary; + + beforeEach(() => { + service.clear(); + + service.hasArtists(BOB_MARLEY, MADONNA, BLONDIE); + service.hasUser(user); + + const token = service.generateToken(user) as AuthSuccess; + musicLibrary = service.login(token.authToken) as MusicLibrary; + }); + + describe("artists", () => { + it("should provide an array of artists", () => { + expect(musicLibrary.artists()).toEqual([ + { id: BOB_MARLEY.id, name: BOB_MARLEY.name }, + { id: MADONNA.id, name: MADONNA.name }, + { id: BLONDIE.id, name: BLONDIE.name }, + ]); + }); + }); + + describe("artist", () => { + describe("when it exists", () => { + it("should provide an artist", () => { + expect(musicLibrary.artist(MADONNA.id)).toEqual({ + id: MADONNA.id, + name: MADONNA.name, + }); + expect(musicLibrary.artist(BLONDIE.id)).toEqual({ + id: BLONDIE.id, + name: BLONDIE.name, + }); + }); + }); + + describe("when it doesnt exist", () => { + it("should provide an artist", () => { + expect(() => musicLibrary.artist("-1")).toThrow( + "No artist with id '-1'" + ); + }); + }); + }); + + describe("albums", () => { + describe("fetching with no filtering", () => { + it("should return all the albums for all the artists", () => { + expect(musicLibrary.albums({})).toEqual([ + ...BOB_MARLEY.albums, + ...BLONDIE.albums, + ...MADONNA.albums, + ]); + }); + }); + + describe("fetching for a single artist", () => { + it("should return them all if the artist has some", () => { + expect(musicLibrary.albums({ artistId: BLONDIE.id })).toEqual( + BLONDIE.albums + ); + }); + + it("should return empty list of the artists does not have any", () => { + expect(musicLibrary.albums({ artistId: MADONNA.id })).toEqual([]); + }); + + it("should return empty list if the artist id is not valid", () => { + expect(musicLibrary.albums({ artistId: uuid() })).toEqual([]); + }); + }); + + describe("fetching with just index", () => { + it("should return everything after", () => { + expect(musicLibrary.albums({ _index: 2 })).toEqual([ + BOB_MARLEY.albums[2], + BLONDIE.albums[0], + BLONDIE.albums[1], + ]); + }); + }); + + describe("fetching with just count", () => { + it("should return first n items", () => { + expect(musicLibrary.albums({ _count: 3 })).toEqual([ + BOB_MARLEY.albums[0], + BOB_MARLEY.albums[1], + BOB_MARLEY.albums[2], + ]); + }); + }); + + describe("fetching with index and count", () => { + it("should be able to return the first page", () => { + expect(musicLibrary.albums({ _index: 0, _count: 2 })).toEqual([ + BOB_MARLEY.albums[0], + BOB_MARLEY.albums[1], + ]); + }); + it("should be able to return the second page", () => { + expect(musicLibrary.albums({ _index: 2, _count: 2 })).toEqual([ + BOB_MARLEY.albums[2], + BLONDIE.albums[0], + ]); + }); + it("should be able to return the last page", () => { + expect(musicLibrary.albums({ _index: 4, _count: 2 })).toEqual([ + BLONDIE.albums[1], + ]); + }); + }); + }); + }); +}); diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index abb937f..0e28736 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -1,35 +1,116 @@ +import { option as O } from "fp-ts"; +import { pipe } from "fp-ts/lib/function"; import { MusicService, Credentials, AuthSuccess, AuthFailure, + Artist, + Album, + MusicLibrary, } from "../src/music_service"; +export type ArtistWithAlbums = Artist & { + albums: Album[]; +}; + +const artistWithAlbumsToArtist = (it: ArtistWithAlbums): Artist => ({ + id: it.id, + name: it.name, +}); + +const getOrThrow = (message: string) => + O.getOrElseW(() => { + throw message; + }); + +type P = (t: T) => boolean; +const all: P = (_: any) => true; +const artistWithId = (id: string): P => (artist: Artist) => + artist.id === id; export class InMemoryMusicService implements MusicService { users: Record = {}; + artists: ArtistWithAlbums[] = []; - login({ username, password }: Credentials): AuthSuccess | AuthFailure { + generateToken({ + username, + password, + }: Credentials): AuthSuccess | AuthFailure { if ( username != undefined && password != undefined && this.users[username] == password ) { - return { authToken: "v1:token123", userId: username, nickname: username }; + return { + authToken: JSON.stringify({ username, password }), + userId: username, + nickname: username, + }; } else { return { message: `Invalid user:${username}` }; } } + login(token: string): MusicLibrary | AuthFailure { + const credentials = JSON.parse(token) as Credentials; + if (this.users[credentials.username] != credentials.password) { + return { + message: "Invalid auth token", + }; + } + return { + artists: () => this.artists.map(artistWithAlbumsToArtist), + artist: (id: string) => + pipe( + this.artists.find((it) => it.id === id), + O.fromNullable, + O.map(artistWithAlbumsToArtist), + getOrThrow(`No artist with id '${id}'`) + ), + albums: ({ + artistId, + _index, + _count, + }: { + artistId?: string; + _index?: number; + _count?: number; + }) => { + const i0 = _index || 0; + const i1 = _count ? i0 + _count : undefined; + return this.artists + .filter( + pipe( + O.fromNullable(artistId), + O.map(artistWithId), + O.getOrElse(() => all) + ) + ) + .flatMap((it) => it.albums) + .slice(i0, i1); + }, + }; + } + hasUser(credentials: Credentials) { this.users[credentials.username] = credentials.password; + return this; } hasNoUsers() { this.users = {}; + return this; + } + + hasArtists(...newArtists: ArtistWithAlbums[]) { + this.artists = [...this.artists, ...newArtists]; + return this; } clear() { this.users = {}; + this.artists = []; + return this; } } diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index 6f3535b..3de68ef 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -3,8 +3,18 @@ import { Express } from "express"; import request from "supertest"; -import { GetAppLinkResult, GetDeviceAuthTokenResult } from "../src/smapi"; -import { getAppLinkMessage } from "./builders"; +import { + GetAppLinkResult, + GetDeviceAuthTokenResult, + GetMetadataResponse, +} from "../src/smapi"; +import { + BLONDIE, + BOB_MARLEY, + getAppLinkMessage, + MADONNA, + someCredentials, +} from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryLinkCodes } from "../src/link_codes"; import { Credentials } from "../src/music_service"; @@ -12,13 +22,53 @@ import makeServer from "../src/server"; import { Service, bonobService, SONOS_DISABLED } from "../src/sonos"; import supersoap from "./supersoap"; -class FooDriver { +class LoggedInSonosDriver { client: Client; token: GetDeviceAuthTokenResult; + currentMetadata?: GetMetadataResponse = undefined; constructor(client: Client, token: GetDeviceAuthTokenResult) { this.client = client; this.token = token; + this.client.addSoapHeader({ + credentials: someCredentials( + this.token.getDeviceAuthTokenResult.authToken + ), + }); + } + + async navigate(...path: string[]) { + let next = path.shift(); + while (next) { + if (next != "root") { + 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}`; + } + } + this.currentMetadata = (await this.getMetadata(next))[0]; + next = path.shift(); + } + return this; + } + + expectTitles(titles: string[]) { + expect( + this.currentMetadata!.getMetadataResult.mediaCollection.map( + (it) => it.title + ) + ).toEqual(titles); + return this; + } + + async getMetadata(id: string) { + return await this.client.getMetadataAsync({ + id, + index: 0, + count: 100, + }); } } @@ -74,7 +124,10 @@ class SonosDriver { return client .getDeviceAuthTokenAsync({ linkCode }) - .then((authToken: [GetDeviceAuthTokenResult, any]) => new FooDriver(client, authToken[0])); + .then( + (authToken: [GetDeviceAuthTokenResult, any]) => + new LoggedInSonosDriver(client, authToken[0]) + ); }, expectFailure: () => { expect(response.status).toEqual(403); @@ -89,7 +142,10 @@ class SonosDriver { describe("scenarios", () => { const bonobUrl = "http://localhost:1234"; const bonob = bonobService("bonob", 123, bonobUrl); - const musicService = new InMemoryMusicService(); + const musicService = new InMemoryMusicService().hasArtists( + BOB_MARLEY, + BLONDIE + ); const linkCodes = new InMemoryLinkCodes(); const server = makeServer( SONOS_DISABLED, @@ -107,22 +163,6 @@ describe("scenarios", () => { }); describe("adding the service", () => { - describe("when the user exists within the music service", () => { - const username = "validuser"; - const password = "validpassword"; - - it("should successfuly sign up", async () => { - musicService.hasUser({ username, password }); - - await sonosDriver - .addService() - .then((it) => it.login({ username, password })) - .then((it) => it.expectSuccess()); - - expect(linkCodes.count()).toEqual(1); - }); - }); - describe("when the user doesnt exists within the music service", () => { const username = "invaliduser"; const password = "invalidpassword"; @@ -138,5 +178,54 @@ describe("scenarios", () => { expect(linkCodes.count()).toEqual(1); }); }); + + describe("when the user exists within the music service", () => { + const username = "validuser"; + const password = "validpassword"; + + beforeEach(() => { + musicService.hasUser({ username, password }); + musicService.hasArtists(BLONDIE, BOB_MARLEY, MADONNA); + }); + + 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 + ) + ) + ); + }); + }); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 1ce235c..a93b9e9 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -1,6 +1,6 @@ import crypto from "crypto"; import request from "supertest"; -import { createClientAsync } from "soap"; +import { Client, createClientAsync } from "soap"; import { DOMParserImpl } from "xmldom-ts"; import * as xpath from "xpath-ts"; @@ -8,11 +8,12 @@ import * as xpath from "xpath-ts"; import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes"; import makeServer from "../src/server"; import { bonobService, SONOS_DISABLED } from "../src/sonos"; -import { STRINGS_ROUTE, LOGIN_ROUTE } from "../src/smapi"; +import { STRINGS_ROUTE, LOGIN_ROUTE, getMetadataResult, container } from "../src/smapi"; -import { aService, getAppLinkMessage } from "./builders"; +import { aService, BLONDIE, BOB_MARLEY, getAppLinkMessage, someCredentials } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import supersoap from "./supersoap"; +import { AuthSuccess } from "../src/music_service"; const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); const select = xpath.useNamespaces({ sonos: "http://sonos.com/sonosapi" }); @@ -42,6 +43,31 @@ describe("service config", () => { }); }); +describe("getMetadataResult", () => { + describe("when there are a zero mediaCollections", () => { + it("should have zero count", () => { + const result = getMetadataResult({ mediaCollection: [] }); + + expect(result.getMetadataResult.count).toEqual(0); + expect(result.getMetadataResult.index).toEqual(0); + expect(result.getMetadataResult.total).toEqual(0); + expect(result.getMetadataResult.mediaCollection).toEqual([]); + }); + }); + + describe("when there are a number of mediaCollections", () => { + it("should add correct counts", () => { + const mediaCollection = [{}, {}]; + const result = getMetadataResult({ mediaCollection }); + + expect(result.getMetadataResult.count).toEqual(2); + expect(result.getMetadataResult.index).toEqual(0); + expect(result.getMetadataResult.total).toEqual(2); + expect(result.getMetadataResult.mediaCollection).toEqual(mediaCollection); + }); + }); +}); + describe("api", () => { const rootUrl = "http://localhost:1234"; const service = bonobService("test-api", 133, rootUrl, "AppLink"); @@ -171,20 +197,24 @@ describe("api", () => { musicService, linkCodes ); - + describe("when there is a linkCode association", () => { it("should return a device auth token", async () => { const linkCode = linkCodes.mint(); - const association = { authToken: "at", userId: "uid", nickname: "nn" }; + const association = { + authToken: "at", + userId: "uid", + nickname: "nn", + }; linkCodes.associate(linkCode, association); - + const ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server, rootUrl), }); - + const result = await ws.getDeviceAuthTokenAsync({ linkCode }); - + expect(result[0]).toEqual({ getDeviceAuthTokenResult: { authToken: association.authToken, @@ -200,20 +230,20 @@ describe("api", () => { }); }); }); - + describe("when there is no linkCode association", () => { it("should return a device auth token", async () => { const linkCode = "invalidLinkCode"; - + const ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, httpClient: supersoap(server, rootUrl), }); - + await ws .getDeviceAuthTokenAsync({ linkCode }) .then(() => { - throw "Shouldnt get here"; + fail("Shouldnt get here"); }) .catch((e: any) => { expect(e.root.Envelope.Body.Fault).toEqual({ @@ -225,7 +255,151 @@ describe("api", () => { }); }); }); - - }); + describe("getMetadata", () => { + const server = makeServer( + SONOS_DISABLED, + service, + rootUrl, + musicService, + linkCodes + ); + + describe("when no credentials header provided", () => { + it("should return a fault of LoginUnauthorized", 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 LoginInvalid", async () => { + const username = "userThatGetsDeleted"; + const password = "password1"; + musicService.hasUser({ username, password }); + const token = musicService.generateToken({ + username, + password, + }) as AuthSuccess; + musicService.hasNoUsers(); + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + + ws.addSoapHeader({ credentials: someCredentials(token.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("when valid credentials are provided", () => { + const username = "validUser"; + const password = "validPassword"; + let token: AuthSuccess; + let ws: Client; + + beforeEach(async () => { + musicService.hasUser({ username, password }); + token = musicService.generateToken({ + username, + password, + }) as AuthSuccess; + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server, rootUrl), + }); + ws.addSoapHeader({ credentials: someCredentials(token.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: [ + container({ id: "artists", title: "Artists" }), + container({ id: "albums", title: "Albums" }), + ], + })); + }); + }); + + describe("asking for artists", () => { + it("should return it", async () => { + musicService.hasArtists(BLONDIE, BOB_MARLEY); + + const artists = await ws.getMetadataAsync({ + id: "artists", + index: 0, + count: 100, + }); + expect(artists[0]).toEqual(getMetadataResult({ + mediaCollection: [BLONDIE, BOB_MARLEY].map(it => container({ id: `artist:${it.id}`, title: it.name })), + })); + }); + }); + + describe("asking for all albums", () => { + it("should return it", async () => { + musicService.hasArtists(BLONDIE, BOB_MARLEY); + + const albums = await ws.getMetadataAsync({ + id: "albums", + index: 0, + count: 100, + }); + expect(albums[0]).toEqual(getMetadataResult({ + mediaCollection: [...BLONDIE.albums, ...BOB_MARLEY.albums].map(it => container({ id: `album:${it.id}`, title: it.name })), + })); + }); + }); + + describe("asking for albums with paging", () => { + it("should return it", async () => { + musicService.hasArtists(BLONDIE, BOB_MARLEY); + + expect(BLONDIE.albums.length).toEqual(2); + expect(BOB_MARLEY.albums.length).toEqual(3); + + const albums = await ws.getMetadataAsync({ + id: "albums", + index: 2, + count: 2, + }); + expect(albums[0]).toEqual(getMetadataResult({ + mediaCollection: [ + container({ id: `album:${BOB_MARLEY.albums[0]!.id}`, title: BOB_MARLEY.albums[0]!.name }), + container({ id: `album:${BOB_MARLEY.albums[1]!.id}`, title: BOB_MARLEY.albums[1]!.name }), + ], + })); + }); + }); + + }); + }); + }); }); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 2b329d4..0e972f9 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -7,7 +7,7 @@ "experimentalDecorators": true, "strictPropertyInitialization": false, "isolatedModules": false, - "strict": false, + "strict": true, "noImplicitAny": false, "typeRoots" : [ "../node_modules/@types" diff --git a/yarn.lock b/yarn.lock index 3668fce..a1b130f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1987,6 +1987,11 @@ forwarded@~0.1.2: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= +fp-ts@^2.9.5: + version "2.9.5" + resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.9.5.tgz#6690cd8b76b84214a38fc77cbbbd04a38f86ea90" + integrity sha512-MiHrA5teO6t8zKArE3DdMPT/Db6v2GUt5yfWnhBTrrsVfeCJUUnV6sgFvjGNBKDmEMqVwRFkEePL7wPwqrLKKA== + fragment-cache@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"