Ability to search by artist, album, track

This commit is contained in:
simojenki
2021-04-20 13:21:58 +10:00
parent 759592767f
commit d3d83df03c
12 changed files with 903 additions and 130 deletions

View File

@@ -130,6 +130,9 @@ export class InMemoryMusicService implements MusicService {
scrobble: async (_: string) => {
return Promise.resolve(true);
},
searchArtists: async (_: string) => Promise.resolve([]),
searchAlbums: async (_: string) => Promise.resolve([]),
searchTracks: async (_: string) => Promise.resolve([]),
});
}

View File

@@ -8,7 +8,7 @@ import {
BROWSER_HEADERS,
DODGY_IMAGE_NAME,
asGenre,
appendMimeTypeToClientFor
appendMimeTypeToClientFor,
} from "../src/navidrome";
import encryption from "../src/encryption";
@@ -72,18 +72,27 @@ describe("appendMimeTypeToUserAgentFor", () => {
});
describe("when contains some mimeTypes", () => {
const streamUserAgent = appendMimeTypeToClientFor(["audio/flac", "audio/ogg"])
const streamUserAgent = appendMimeTypeToClientFor([
"audio/flac",
"audio/ogg",
]);
describe("and the track mimeType is in the array", () => {
it("should return bonob+mimeType", () => {
expect(streamUserAgent(aTrack({ mimeType: "audio/flac"}))).toEqual("bonob+audio/flac")
expect(streamUserAgent(aTrack({ mimeType: "audio/ogg"}))).toEqual("bonob+audio/ogg")
expect(streamUserAgent(aTrack({ mimeType: "audio/flac" }))).toEqual(
"bonob+audio/flac"
);
expect(streamUserAgent(aTrack({ mimeType: "audio/ogg" }))).toEqual(
"bonob+audio/ogg"
);
});
});
describe("and the track mimeType is not in the array", () => {
it("should return bonob", () => {
expect(streamUserAgent(aTrack({ mimeType: "audio/mp3"}))).toEqual("bonob")
expect(streamUserAgent(aTrack({ mimeType: "audio/mp3" }))).toEqual(
"bonob"
);
});
});
});
@@ -94,7 +103,7 @@ const ok = (data: string) => ({
data,
});
const artistInfoXml = (
const getArtistInfoXml = (
artist: Artist
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<artistInfo>
@@ -162,14 +171,18 @@ const albumListXml = (
</albumList>
</subsonic-response>`;
const artistXml = (
const artistXml = (artist: Artist) => `<artist id="${artist.id}" name="${
artist.name
}" albumCount="${artist.albums.length}" artistImageUrl="....">
${artist.albums.map((album) =>
albumXml(artist, album)
)}
</artist>`;
const getArtistXml = (
artist: Artist
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<artist id="${artist.id}" name="${artist.name}" albumCount="${
artist.albums.length
}" artistImageUrl="....">
${artist.albums.map((album) => albumXml(artist, album))}
</artist>
${artistXml(artist)}
</subsonic-response>`;
const genresXml = (
@@ -203,6 +216,32 @@ const getSongXml = (
)}
</subsonic-response>`;
export type ArtistWithAlbum = {
artist: Artist;
album: Album;
};
const searchResult3 = ({
artists,
albums,
tracks,
}: Partial<{
artists: Artist[];
albums: ArtistWithAlbum[];
tracks: Track[];
}>) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.41.1 (43bb0758)">
<searchResult3>
${(artists || []).map((it) =>
artistXml({
...it,
albums: [],
})
)}
${(albums || []).map((it) => albumXml(it.artist, it.album, []))}
${(tracks || []).map((it) => songXml(it))}
</searchResult3>
</subsonic-response>`;
const EMPTY = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`;
const PING_OK = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`;
@@ -214,7 +253,11 @@ describe("Navidrome", () => {
const salt = "saltysalty";
const streamClientApplication = jest.fn();
const navidrome = new Navidrome(url, encryption("secret"), streamClientApplication);
const navidrome = new Navidrome(
url,
encryption("secret"),
streamClientApplication
);
const mockedRandomString = (randomString as unknown) as jest.Mock;
const mockGET = jest.fn();
@@ -356,10 +399,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
);
});
@@ -419,10 +462,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
);
});
@@ -482,10 +525,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
);
});
@@ -545,10 +588,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
);
});
@@ -603,10 +646,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
);
});
@@ -655,10 +698,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
);
});
@@ -705,10 +748,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
);
});
@@ -1553,7 +1596,7 @@ describe("Navidrome", () => {
describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => {
it("should return undefined values", async () => {
const stream = {
pipe: jest.fn()
pipe: jest.fn(),
};
const streamResponse = {
@@ -1592,7 +1635,7 @@ describe("Navidrome", () => {
describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => {
it("should return undefined values", async () => {
const stream = {
pipe: jest.fn()
pipe: jest.fn(),
};
const streamResponse = {
@@ -1635,7 +1678,7 @@ describe("Navidrome", () => {
describe("navidrome returns a 200", () => {
it("should return the content", async () => {
const stream = {
pipe: jest.fn()
pipe: jest.fn(),
};
const streamResponse = {
@@ -1712,7 +1755,7 @@ describe("Navidrome", () => {
return expect(
musicLibrary.stream({ trackId, range: undefined })
).rejects.toEqual(`Navidrome failed with a 400`);
).rejects.toEqual(`Navidrome failed with a 400 status`);
});
});
});
@@ -1720,7 +1763,7 @@ describe("Navidrome", () => {
describe("with range specified", () => {
it("should send the range to navidrome", async () => {
const stream = {
pipe: jest.fn()
pipe: jest.fn(),
};
const range = "1000-2000";
@@ -1780,7 +1823,7 @@ describe("Navidrome", () => {
it("should user the custom StreamUserAgent when calling navidrome", async () => {
const clientApplication = `bonob-${uuid()}`;
streamClientApplication.mockReturnValue(clientApplication);
const streamResponse = {
status: 200,
headers: {
@@ -1788,27 +1831,29 @@ describe("Navidrome", () => {
},
data: Buffer.from("the track", "ascii"),
};
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track))))
.mockImplementationOnce(() =>
Promise.resolve(ok(getSongXml(track)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [track])))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.stream({ trackId, range: undefined }));
expect(streamClientApplication).toHaveBeenCalledWith(track);
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
params: {
id: trackId,
...authParams,
c: clientApplication
c: clientApplication,
},
headers: {
"User-Agent": "bonob",
@@ -1817,13 +1862,13 @@ describe("Navidrome", () => {
});
});
});
describe("when range specified", () => {
it("should user the custom StreamUserAgent when calling navidrome", async () => {
const range = "1000-2000";
const clientApplication = `bonob-${uuid()}`;
streamClientApplication.mockReturnValue(clientApplication);
const streamResponse = {
status: 200,
headers: {
@@ -1831,27 +1876,29 @@ describe("Navidrome", () => {
},
data: Buffer.from("the track", "ascii"),
};
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track))))
.mockImplementationOnce(() =>
Promise.resolve(ok(getSongXml(track)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [track])))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.stream({ trackId, range }));
expect(streamClientApplication).toHaveBeenCalledWith(track);
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
params: {
id: trackId,
...authParams,
c: clientApplication
c: clientApplication,
},
headers: {
"User-Agent": "bonob",
@@ -1968,10 +2015,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2035,10 +2082,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2113,10 +2160,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2180,10 +2227,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2256,10 +2303,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2335,10 +2382,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2403,10 +2450,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2482,10 +2529,10 @@ describe("Navidrome", () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
Promise.resolve(ok(getArtistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -2582,4 +2629,369 @@ describe("Navidrome", () => {
});
});
});
describe("searchArtists", () => {
describe("when there is 1 search results", () => {
it("should return true", async () => {
const artist1 = anArtist({ name: "foo woo" });
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ artists: [artist1] })))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchArtists("foo"));
expect(result).toEqual([artistToArtistSummary(artist1)]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
artistCount: 20,
albumCount: 0,
songCount: 0,
...authParams,
},
headers,
});
});
});
describe("when there are many search results", () => {
it("should return true", async () => {
const artist1 = anArtist({ name: "foo woo" });
const artist2 = anArtist({ name: "foo choo" });
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ artists: [artist1, artist2] })))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchArtists("foo"));
expect(result).toEqual([
artistToArtistSummary(artist1),
artistToArtistSummary(artist2),
]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
artistCount: 20,
albumCount: 0,
songCount: 0,
...authParams,
},
headers,
});
});
});
describe("when there are no search results", () => {
it("should return []", async () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ artists: [] })))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchArtists("foo"));
expect(result).toEqual([]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
artistCount: 20,
albumCount: 0,
songCount: 0,
...authParams,
},
headers,
});
});
});
});
describe("searchAlbums", () => {
describe("when there is 1 search results", () => {
it("should return true", async () => {
const artist = anArtist({ name: "#1" });
const album = anAlbum({
name: "foo woo",
genre: { id: "pop", name: "pop" },
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ albums: [{ artist, album }] })))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchAlbums("foo"));
expect(result).toEqual([albumToAlbumSummary(album)]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
albumCount: 20,
artistCount: 0,
songCount: 0,
...authParams,
},
headers,
});
});
});
describe("when there are many search results", () => {
it("should return true", async () => {
const artist1 = anArtist({ name: "artist1" });
const album1 = anAlbum({
name: "album1",
genre: { id: "pop", name: "pop" },
});
const artist2 = anArtist({ name: "artist2" });
const album2 = anAlbum({
name: "album2",
genre: { id: "pop", name: "pop" },
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(
ok(
searchResult3({
albums: [
{ artist: artist1, album: album1 },
{ artist: artist2, album: album2 },
],
})
)
)
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchAlbums("moo"));
expect(result).toEqual([
albumToAlbumSummary(album1),
albumToAlbumSummary(album2),
]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "moo",
albumCount: 20,
artistCount: 0,
songCount: 0,
...authParams,
},
headers,
});
});
});
describe("when there are no search results", () => {
it("should return []", async () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ albums: [] })))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchAlbums("foo"));
expect(result).toEqual([]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
albumCount: 20,
artistCount: 0,
songCount: 0,
...authParams,
},
headers,
});
});
});
});
describe("searchSongs", () => {
describe("when there is 1 search results", () => {
it("should return true", async () => {
const pop = asGenre("Pop");
const album = anAlbum({ id: "album1", name: "Burnin", genre: pop });
const artist = anArtist({
id: "artist1",
name: "Bob Marley",
albums: [album],
});
const track = aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album),
genre: pop,
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ tracks: [track] })))
)
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track))))
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album, [])))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchTracks("foo"));
expect(result).toEqual([track]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
songCount: 20,
artistCount: 0,
albumCount: 0,
...authParams,
},
headers,
});
});
});
describe("when there are many search results", () => {
it("should return true", async () => {
const pop = asGenre("Pop");
const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop });
const artist1 = anArtist({
id: "artist1",
name: "Bob Marley",
albums: [album1],
});
const track1 = aTrack({
id: "track1",
artist: artistToArtistSummary(artist1),
album: albumToAlbumSummary(album1),
genre: pop,
});
const album2 = anAlbum({ id: "album2", name: "Bobbin", genre: pop });
const artist2 = anArtist({
id: "artist2",
name: "Jane Marley",
albums: [album2],
});
const track2 = aTrack({
id: "track2",
artist: artistToArtistSummary(artist2),
album: albumToAlbumSummary(album2),
genre: pop,
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(
ok(
searchResult3({
tracks: [track1, track2],
})
)
)
)
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track1))))
.mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track2))))
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist1, album1, [])))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist2, album2, [])))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchTracks("moo"));
expect(result).toEqual([track1, track2]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "moo",
songCount: 20,
artistCount: 0,
albumCount: 0,
...authParams,
},
headers,
});
});
});
describe("when there are no search results", () => {
it("should return []", async () => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(searchResult3({ tracks: [] })))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.searchTracks("foo"));
expect(result).toEqual([]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, {
params: {
query: "foo",
songCount: 20,
artistCount: 0,
albumCount: 0,
...authParams,
},
headers,
});
});
});
});
});

View File

@@ -17,9 +17,11 @@ import {
PRESENTATION_MAP_ROUTE,
SONOS_RECOMMENDED_IMAGE_SIZES,
track,
artist,
album,
defaultAlbumArtURI,
defaultArtistArtURI,
searchResult,
} from "../src/smapi";
import {
@@ -36,8 +38,13 @@ import {
} from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service";
import supersoap from "./supersoap";
import { artistToArtistSummary, MusicService } from "../src/music_service";
import {
albumToAlbumSummary,
artistToArtistSummary,
MusicService,
} from "../src/music_service";
import { AccessTokens } from "../src/access_tokens";
import dayjs from "dayjs";
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
@@ -282,11 +289,17 @@ describe("api", () => {
albums: jest.fn(),
tracks: jest.fn(),
track: jest.fn(),
searchArtists: jest.fn(),
searchAlbums: jest.fn(),
searchTracks: jest.fn(),
};
const accessTokens = {
mint: jest.fn(),
authTokenFor: jest.fn(),
};
const clock = {
now: jest.fn(),
};
const server = makeServer(
SONOS_DISABLED,
@@ -294,7 +307,8 @@ describe("api", () => {
rootUrl,
(musicService as unknown) as MusicService,
(linkCodes as unknown) as LinkCodes,
(accessTokens as unknown) as AccessTokens
(accessTokens as unknown) as AccessTokens,
clock
);
beforeEach(() => {
@@ -466,6 +480,179 @@ describe("api", () => {
});
});
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: {
favorites: `${now.unix()}`,
catalog: `${now.unix()}`,
pollInterval: 120,
},
});
});
});
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.only("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) => track(rootUrl, accessToken, it)),
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 () => {
@@ -570,6 +757,27 @@ describe("api", () => {
});
});
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];
@@ -984,7 +1192,7 @@ describe("api", () => {
_count: paging.count,
});
});
});
});
describe("asking for recently played albums", () => {
const recentlyPlayed = [rock2, rock1, pop2];
@@ -1027,7 +1235,7 @@ describe("api", () => {
_count: paging.count,
});
});
});
});
describe("asking for most played albums", () => {
const mostPlayed = [rock2, rock1, pop2];
@@ -1070,8 +1278,8 @@ describe("api", () => {
_count: paging.count,
});
});
});
});
describe("asking for recently added albums", () => {
const recentlyAdded = [pop4, pop3, pop2];
@@ -1113,7 +1321,7 @@ describe("api", () => {
_count: paging.count,
});
});
});
});
describe("asking for all albums", () => {
beforeEach(() => {
@@ -1377,7 +1585,7 @@ describe("api", () => {
describe("when invalid credentials are provided", () => {
it("should return a fault of LoginUnauthorized", async () => {
musicService.login.mockRejectedValue("booom!")
musicService.login.mockRejectedValue("booom!");
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
@@ -1399,7 +1607,7 @@ describe("api", () => {
describe("when valid credentials are provided", () => {
let ws: Client;
const authToken = `authToken-${uuid()}`
const authToken = `authToken-${uuid()}`;
const accessToken = `accessToken-${uuid()}`;
beforeEach(async () => {
@@ -1425,7 +1633,7 @@ describe("api", () => {
});
beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist)
musicLibrary.artist.mockResolvedValue(artist);
});
describe("when all albums fit on a page", () => {
@@ -1434,18 +1642,20 @@ describe("api", () => {
index: 0,
count: 100,
};
const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`,
...paging
...paging,
});
expect(root[0]).toEqual({
getExtendedMetadataResult: {
count: "3",
index: "0",
total: "3",
mediaCollection: artist.albums.map(it => album(rootUrl, accessToken, it))
mediaCollection: artist.albums.map((it) =>
album(rootUrl, accessToken, it)
),
},
});
});
@@ -1457,22 +1667,24 @@ describe("api", () => {
index: 1,
count: 2,
};
const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`,
...paging
...paging,
});
expect(root[0]).toEqual({
getExtendedMetadataResult: {
count: "2",
index: "1",
total: "3",
mediaCollection: [album2, album3].map(it => album(rootUrl, accessToken, it))
mediaCollection: [album2, album3].map((it) =>
album(rootUrl, accessToken, it)
),
},
});
});
});
});
});
describe("when it has similar artists", () => {
@@ -1485,7 +1697,7 @@ describe("api", () => {
});
beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist)
musicLibrary.artist.mockResolvedValue(artist);
});
it("should return a RELATED_ARTISTS browse option", async () => {
@@ -1496,7 +1708,7 @@ describe("api", () => {
const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`,
...paging
...paging,
});
expect(root[0]).toEqual({
@@ -1523,7 +1735,7 @@ describe("api", () => {
});
beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist)
musicLibrary.artist.mockResolvedValue(artist);
});
it("should not return a RELATED_ARTISTS browse option", async () => {
@@ -1568,7 +1780,7 @@ describe("api", () => {
describe("when invalid credentials are provided", () => {
it("should return a fault of LoginUnauthorized", async () => {
musicService.login.mockRejectedValue("Credentials not found")
musicService.login.mockRejectedValue("Credentials not found");
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
@@ -1589,12 +1801,12 @@ describe("api", () => {
});
describe("when valid credentials are provided", () => {
const authToken = `authToken-${uuid()}`
const authToken = `authToken-${uuid()}`;
let ws: Client;
const accessToken = `temporaryAccessToken-${uuid()}`;
beforeEach(async () => {
musicService.login.mockResolvedValue(musicLibrary)
musicService.login.mockResolvedValue(musicLibrary);
accessTokens.mint.mockReturnValue(accessToken);
ws = await createClientAsync(`${service.uri}?wsdl`, {
@@ -1656,7 +1868,9 @@ describe("api", () => {
httpClient: supersoap(server, rootUrl),
});
ws.addSoapHeader({ credentials: someCredentials("some invalid token") });
ws.addSoapHeader({
credentials: someCredentials("some invalid token"),
});
await ws
.getMediaMetadataAsync({ id: "track:123" })
.then(() => fail("shouldnt get here"))
@@ -1695,11 +1909,7 @@ describe("api", () => {
});
expect(root[0]).toEqual({
getMediaMetadataResult: track(
rootUrl,
accessToken,
someTrack
),
getMediaMetadataResult: track(rootUrl, accessToken, someTrack),
});
expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);

View File

@@ -20,8 +20,7 @@ import sonos, {
asCustomdForm,
bonobService,
Service,
STRINGS_VERSION,
PRESENTATION_MAP_VERSION,
PRESENTATION_AND_STRINGS_VERSION,
BONOB_CAPABILITIES,
} from "../src/sonos";
@@ -119,11 +118,11 @@ describe("sonos", () => {
secureUri: `http://bonob.example.com/ws/sonos`,
strings: {
uri: `http://bonob.example.com/sonos/strings.xml`,
version: STRINGS_VERSION,
version: PRESENTATION_AND_STRINGS_VERSION,
},
presentation: {
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
version: PRESENTATION_MAP_VERSION,
version: PRESENTATION_AND_STRINGS_VERSION,
},
pollInterval: 1200,
authType: "AppLink",
@@ -142,11 +141,11 @@ describe("sonos", () => {
secureUri: `http://bonob.example.com/ws/sonos`,
strings: {
uri: `http://bonob.example.com/sonos/strings.xml`,
version: STRINGS_VERSION,
version: PRESENTATION_AND_STRINGS_VERSION,
},
presentation: {
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
version: PRESENTATION_MAP_VERSION,
version: PRESENTATION_AND_STRINGS_VERSION,
},
pollInterval: 1200,
authType: "AppLink",
@@ -165,11 +164,11 @@ describe("sonos", () => {
secureUri: `http://bonob.example.com/ws/sonos`,
strings: {
uri: `http://bonob.example.com/sonos/strings.xml`,
version: STRINGS_VERSION,
version: PRESENTATION_AND_STRINGS_VERSION,
},
presentation: {
uri: `http://bonob.example.com/sonos/presentationMap.xml`,
version: PRESENTATION_MAP_VERSION,
version: PRESENTATION_AND_STRINGS_VERSION,
},
pollInterval: 1200,
authType: "DeviceLink",