Files
bonob/tests/sonos.test.ts

868 lines
25 KiB
TypeScript

import qs from "querystring"
import { SonosManager, SonosDevice } from "@svrooij/sonos";
import {
MusicServicesService,
MusicService,
} from "@svrooij/sonos/lib/services";
jest.mock("@svrooij/sonos");
import axios from "axios";
jest.mock("axios");
import { v4 as uuid } from 'uuid';
import { AMAZON_MUSIC, APPLE_MUSIC, AUDIBLE } from "./music_services";
import sonos, {
SONOS_DISABLED,
asDevice,
asService,
asCustomdForm,
bonobService,
Service,
PRESENTATION_AND_STRINGS_VERSION,
BONOB_CAPABILITIES,
asRemoveCustomdForm,
} from "../src/sonos";
import { aSonosDevice, aService } from "./builders";
import url from "../src/url_builder";
const mockSonosManagerConstructor = <jest.Mock<SonosManager>>SonosManager;
describe("sonos", () => {
beforeEach(() => {
mockSonosManagerConstructor.mockClear();
});
describe("asService", () => {
it("should convert", () => {
const musicService: MusicService = {
Name: "Amazon Music",
Version: "1.1",
Uri: "https://sonos.amazonmusic.com/",
SecureUri: "https://sonos.amazonmusic.com/",
ContainerType: "MService",
Capabilities: "2208321",
Presentation: {
Strings: {
Version: "23",
Uri: "https://sonos.amazonmusic.com/strings.xml",
},
PresentationMap: {
Version: "17",
Uri: "https://sonos.amazonmusic.com/PresentationMap.xml",
},
},
Id: 201,
Policy: { Auth: "DeviceLink", PollInterval: 60 },
Manifest: {
Uri: "",
Version: "",
},
};
expect(asService(musicService)).toEqual({
name: "Amazon Music",
sid: 201,
uri: "https://sonos.amazonmusic.com/",
secureUri: "https://sonos.amazonmusic.com/",
strings: {
uri: "https://sonos.amazonmusic.com/strings.xml",
version: "23",
},
presentation: {
uri: "https://sonos.amazonmusic.com/PresentationMap.xml",
version: "17",
},
pollInterval: 60,
authType: "DeviceLink",
});
});
});
describe("asDevice", () => {
it("should convert", () => {
const musicServicesService = {
ListAndParseAvailableServices: jest.fn(),
};
const device = {
Name: "d1",
GroupName: "g1",
Host: "127.0.0.222",
Port: 123,
MusicServicesService: (musicServicesService as unknown) as MusicServicesService,
} as SonosDevice;
musicServicesService.ListAndParseAvailableServices.mockResolvedValue([
AMAZON_MUSIC,
APPLE_MUSIC,
]);
expect(asDevice(device)).toEqual({
name: "d1",
group: "g1",
ip: "127.0.0.222",
port: 123,
});
});
});
describe("bonobService", () => {
describe("when the bonob url does not have a trailing /", () => {
it("should return a valid bonob service", () => {
expect(
bonobService("some-bonob", 876, url("http://bonob.example.com"))
).toEqual({
name: "some-bonob",
sid: 876,
uri: `http://bonob.example.com/ws/sonos`,
secureUri: `http://bonob.example.com/ws/sonos`,
strings: {
uri: `http://bonob.example.com/sonos/strings.xml`,
version: PRESENTATION_AND_STRINGS_VERSION,
},
presentation: {
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
version: PRESENTATION_AND_STRINGS_VERSION,
},
pollInterval: 1200,
authType: "AppLink",
});
});
});
describe("when the bonob url does have a trailing /", () => {
it("should return a valid bonob service", () => {
expect(
bonobService("some-bonob", 876, url("http://bonob.example.com/"))
).toEqual({
name: "some-bonob",
sid: 876,
uri: `http://bonob.example.com/ws/sonos`,
secureUri: `http://bonob.example.com/ws/sonos`,
strings: {
uri: `http://bonob.example.com/sonos/strings.xml`,
version: PRESENTATION_AND_STRINGS_VERSION,
},
presentation: {
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
version: PRESENTATION_AND_STRINGS_VERSION,
},
pollInterval: 1200,
authType: "AppLink",
});
});
});
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, url("http://bonob.example.com"), 'DeviceLink')
).toEqual({
name: "some-bonob",
sid: 876,
uri: `http://bonob.example.com/ws/sonos`,
secureUri: `http://bonob.example.com/ws/sonos`,
strings: {
uri: `http://bonob.example.com/sonos/strings.xml`,
version: PRESENTATION_AND_STRINGS_VERSION,
},
presentation: {
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
version: PRESENTATION_AND_STRINGS_VERSION,
},
pollInterval: 1200,
authType: "DeviceLink",
});
});
});
});
describe("asCustomdForm", () => {
describe("when all values specified", () => {
it("should return a form", () => {
const csrfToken = uuid();
const service: Service = {
name: "the new service",
sid: 888,
uri: "http://aa.example.com",
secureUri: "https://aa.example.com",
strings: { uri: "http://strings.example.com", version: "26" },
presentation: {
uri: "http://presentation.example.com",
version: "27",
},
pollInterval: 5600,
authType: "UserId",
};
expect(asCustomdForm(csrfToken, service)).toEqual({
csrfToken,
sid: "888",
name: "the new service",
uri: "http://aa.example.com",
secureUri: "https://aa.example.com",
pollInterval: "5600",
authType: "UserId",
stringsVersion: "26",
stringsUri: "http://strings.example.com",
presentationMapVersion: "27",
presentationMapUri: "http://presentation.example.com",
containerType: "MService",
caps: BONOB_CAPABILITIES
});
});
});
describe("when pollInterval undefined", () => {
it("should default to 1200", () => {
const service: Service = aService({ pollInterval: undefined });
expect(asCustomdForm(uuid(), service).pollInterval).toEqual("1200");
});
});
describe("when strings and presentation are undefined", () => {
it("should default to 1200", () => {
const service: Service = aService({
strings: { uri: undefined, version: undefined },
presentation: { uri: undefined, version: undefined },
});
const form = asCustomdForm(uuid(), service)
expect(form.stringsUri).toEqual("");
expect(form.stringsVersion).toEqual("0");
expect(form.presentationMapUri).toEqual("");
expect(form.presentationMapVersion).toEqual("0");
});
});
});
describe("asRemoveCustomdForm", () => {
it("should return a form", () => {
const csrfToken = uuid();
const sid = 123;
expect(asRemoveCustomdForm(csrfToken, sid)).toEqual({
csrfToken,
sid: "123"
});
});
});
describe("when is disabled", () => {
it("should return a disabled client", async () => {
const disabled = sonos({ enabled: false });
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.remove(123)).toEqual(false);
});
});
describe("sonos device discovery", () => {
const device1 = {
Name: "device1",
GroupName: "group1",
Host: "127.0.0.11",
Port: 111,
} as SonosDevice;
const device2 = {
Name: "device2",
GroupName: "group2",
Host: "127.0.0.22",
Port: 222,
} as SonosDevice;
describe("when no sonos seed host is provided", () => {
it("should perform auto-discovery", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const actualDevices = await sonos({ enabled: true }).devices();
expect(SonosManager).toHaveBeenCalledTimes(1);
expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10);
expect(actualDevices).toEqual([]);
});
});
describe("when sonos seed host is empty string", () => {
it("should perform auto-discovery", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const actualDevices = await sonos({ enabled: true, seedHost: "" }).devices();
expect(SonosManager).toHaveBeenCalledTimes(1);
expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10);
expect(actualDevices).toEqual([]);
});
});
describe("when a sonos seed host is provided", () => {
it("should perform auto-discovery", async () => {
const seedHost = "theSeedsOfLife";
const sonosManager = {
InitializeFromDevice: jest.fn(),
Devices: [],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeFromDevice.mockResolvedValue(true);
const actualDevices = await sonos({ enabled: true, seedHost }).devices();
expect(SonosManager).toHaveBeenCalledTimes(1);
expect(sonosManager.InitializeFromDevice).toHaveBeenCalledWith(
seedHost
);
expect(actualDevices).toEqual([]);
});
});
describe("when some devices are found", () => {
it("should be able to return them", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const actualDevices = await sonos({ enabled: true, seedHost: undefined }).devices();
expect(actualDevices).toEqual([
{
name: device1.Name,
group: device1.GroupName,
ip: device1.Host,
port: device1.Port,
},
{
name: device2.Name,
group: device2.GroupName,
ip: device2.Host,
port: device2.Port,
},
]);
});
});
describe("when SonosManager initialisation returns false", () => {
it("should return no devices", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(false);
expect(await sonos({ enabled: true, seedHost: "" }).devices()).toEqual([]);
});
});
});
describe("sonos service discovery", () => {
const device1 = {
Name: "device1",
GroupName: "group1",
Host: "127.0.0.11",
Port: 111,
MusicServicesService: {
ListAndParseAvailableServices: jest.fn(),
},
};
const device2 = {
Name: "device2",
GroupName: "group2",
Host: "127.0.0.22",
Port: 222,
};
beforeEach(() => {
device1.MusicServicesService.ListAndParseAvailableServices.mockClear();
});
describe("when there are no devices", () => {
it("should return no services", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const services = await sonos().services();
expect(SonosManager).toHaveBeenCalledTimes(1);
expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10);
expect(services).toEqual([]);
});
});
describe("when there are some devices", () => {
it("should return the services from the first device", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
device1.MusicServicesService.ListAndParseAvailableServices.mockResolvedValue(
[AMAZON_MUSIC, APPLE_MUSIC, AUDIBLE]
);
const services = await sonos().services();
expect(SonosManager).toHaveBeenCalledTimes(1);
expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10);
expect(services).toEqual([
asService(AMAZON_MUSIC),
asService(APPLE_MUSIC),
asService(AUDIBLE),
]);
});
});
describe("when SonosManager initialisation returns false", () => {
it("should return no devices", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(false);
const services = await sonos().services();
expect(SonosManager).toHaveBeenCalledTimes(1);
expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10);
expect(services).toEqual([]);
});
});
describe("when getting devices fails", () => {
it("should return no devices", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: () => {
throw Error("Boom");
},
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const services = await sonos().services();
expect(SonosManager).toHaveBeenCalledTimes(1);
expect(sonosManager.InitializeWithDiscovery).toHaveBeenCalledWith(10);
expect(services).toEqual([]);
});
});
});
describe("registering a service", () => {
const device1 = aSonosDevice({
Name: "d1",
Host: "127.0.0.11",
Port: 111,
});
const device2 = aSonosDevice({
Name: "d2",
});
const POST_CONFIG = {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
};
const serviceToAdd = aService({
name: "new service",
sid: 123,
});
const mockGet = jest.fn();
const mockPost = jest.fn();
beforeEach(() => {
mockGet.mockClear();
mockPost.mockClear();
axios.get = mockGet;
axios.post = mockPost;
});
describe("when successful", () => {
it("should post the service into the first found sonos device, returning true", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const csrfToken = `csrfToken-${uuid()}`;
mockGet.mockResolvedValue({
status: 200,
data: `<html><input name='csrfToken' value='${csrfToken}'></html>`,
});
mockPost.mockResolvedValue({ status: 200, data: "" });
const result = await sonos().register(serviceToAdd);
expect(mockGet).toHaveBeenCalledWith(
`http://${device1.Host}:${device1.Port}/customsd`
);
expect(mockPost).toHaveBeenCalledWith(
`http://${device1.Host}:${device1.Port}/customsd`,
new URLSearchParams(qs.stringify(asCustomdForm(csrfToken, serviceToAdd))),
POST_CONFIG
);
expect(result).toEqual(true);
});
});
describe("when cannot find any devices", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const result = await sonos().register(serviceToAdd);
expect(mockGet).not.toHaveBeenCalled();
expect(mockPost).not.toHaveBeenCalled();
expect(result).toEqual(false);
});
});
describe("when cannot get csrfToken", () => {
describe("when the token is missing", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
mockGet.mockResolvedValue({
status: 200,
data: `<html></html>`,
});
const result = await sonos().register(serviceToAdd);
expect(mockPost).not.toHaveBeenCalled();
expect(result).toEqual(false);
});
});
describe("when the token call returns a non 200", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
mockGet.mockResolvedValue({
status: 400,
data: `<html></html>`,
});
const result = await sonos().register(serviceToAdd);
expect(mockPost).not.toHaveBeenCalled();
expect(result).toEqual(false);
});
});
});
describe("when posting in the service definition fails", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const csrfToken = `csrfToken-${uuid()}`;
mockGet.mockResolvedValue({
status: 200,
data: `<html><input name='csrfToken' value='${csrfToken}'></html>`,
});
mockPost.mockResolvedValue({ status: 500, data: "" });
const result = await sonos().register(serviceToAdd);
expect(result).toEqual(false);
});
});
});
describe("removing a service", () => {
const device1 = aSonosDevice({
Name: "d1",
Host: "127.0.0.11",
Port: 111,
});
const device2 = aSonosDevice({
Name: "d2",
});
const POST_CONFIG = {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
};
const sidToRemove = 333;
const mockGet = jest.fn();
const mockPost = jest.fn();
beforeEach(() => {
mockGet.mockClear();
mockPost.mockClear();
axios.get = mockGet;
axios.post = mockPost;
});
describe("when successful", () => {
it("should post the remove into the first found sonos device, returning true", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const csrfToken = `csrfToken-${uuid()}`;
mockGet.mockResolvedValue({
status: 200,
data: `<html><input name='csrfToken' value='${csrfToken}'></html>`,
});
mockPost.mockResolvedValue({ status: 200, data: "" });
const result = await sonos().remove(sidToRemove);
expect(mockGet).toHaveBeenCalledWith(
`http://${device1.Host}:${device1.Port}/customsd`
);
expect(mockPost).toHaveBeenCalledWith(
`http://${device1.Host}:${device1.Port}/customsd`,
new URLSearchParams(qs.stringify(asRemoveCustomdForm(csrfToken, sidToRemove))),
POST_CONFIG
);
expect(result).toEqual(true);
});
});
describe("when cannot find any devices", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const result = await sonos().remove(sidToRemove);
expect(mockGet).not.toHaveBeenCalled();
expect(mockPost).not.toHaveBeenCalled();
expect(result).toEqual(false);
});
});
describe("when cannot get csrfToken", () => {
describe("when the token is missing", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
mockGet.mockResolvedValue({
status: 200,
data: `<html></html>`,
});
const result = await sonos().remove(sidToRemove);
expect(mockPost).not.toHaveBeenCalled();
expect(result).toEqual(false);
});
});
describe("when the token call returns a non 200", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
mockGet.mockResolvedValue({
status: 400,
data: `<html></html>`,
});
const result = await sonos().remove(sidToRemove);
expect(mockPost).not.toHaveBeenCalled();
expect(result).toEqual(false);
});
});
});
describe("when posting in the sid to delete fails", () => {
it("should return false", async () => {
const sonosManager = {
InitializeWithDiscovery: jest.fn(),
Devices: [device1, device2],
};
mockSonosManagerConstructor.mockReturnValue(
(sonosManager as unknown) as SonosManager
);
sonosManager.InitializeWithDiscovery.mockResolvedValue(true);
const csrfToken = `csrfToken-${uuid()}`;
mockGet.mockResolvedValue({
status: 200,
data: `<html><input name='csrfToken' value='${csrfToken}'></html>`,
});
mockPost.mockResolvedValue({ status: 500, data: "" });
const result = await sonos().remove(sidToRemove);
expect(result).toEqual(false);
});
});
});
});