Tests for browsing of artists and albums

This commit is contained in:
simojenki
2021-03-01 12:46:23 +11:00
parent 0cb02707f1
commit 3b350c4402
12 changed files with 778 additions and 57 deletions

View File

@@ -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: [],
};

View File

@@ -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],
]);
});
});
});
});
});

View File

@@ -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: T) => boolean;
const all: P<any> = (_: any) => true;
const artistWithId = (id: string): P<Artist> => (artist: Artist) =>
artist.id === id;
export class InMemoryMusicService implements MusicService {
users: Record<string, string> = {};
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;
}
}

View File

@@ -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
)
)
);
});
});
});
});

View File

@@ -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 }),
],
}));
});
});
});
});
});
});

View File

@@ -7,7 +7,7 @@
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"isolatedModules": false,
"strict": false,
"strict": true,
"noImplicitAny": false,
"typeRoots" : [
"../node_modules/@types"