285 lines
8.9 KiB
TypeScript
285 lines
8.9 KiB
TypeScript
// @vitest-environment node
|
|
import { Response } from "undici";
|
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
vi.hoisted(() => {
|
|
process.env.SKIP_ENV_VALIDATION = "true";
|
|
process.env.SECRET_ENCRYPTION_KEY = "0".repeat(64);
|
|
});
|
|
|
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
|
|
|
import type { IntegrationSecret } from "../../base/types";
|
|
import { NavidromeIntegration } from "../navidrome-integration";
|
|
|
|
vi.mock("@homarr/core/infrastructure/http", () => ({
|
|
fetchWithTrustedCertificatesAsync: vi.fn(),
|
|
}));
|
|
|
|
const TEST_URL = "https://navidrome.example.com";
|
|
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
|
|
|
const secrets: IntegrationSecret[] = [
|
|
{ kind: "username", value: "admin" },
|
|
{ kind: "password", value: "secret" },
|
|
];
|
|
|
|
const toUrlString = (url: unknown): string => String(url);
|
|
|
|
const createIntegration = () =>
|
|
new NavidromeIntegration({
|
|
id: "test-navidrome",
|
|
name: "Test Navidrome",
|
|
url: TEST_URL,
|
|
externalUrl: null,
|
|
decryptedSecrets: secrets,
|
|
});
|
|
|
|
const subsonicOk = <T extends Record<string, unknown>>(data: T) =>
|
|
JSON.stringify({
|
|
"subsonic-response": { status: "ok", version: "1.16.1", ...data },
|
|
});
|
|
|
|
const subsonicFailed = (message: string, code = 70) =>
|
|
JSON.stringify({
|
|
"subsonic-response": { status: "failed", error: { code, message } },
|
|
});
|
|
|
|
const makeAlbums = (count: number, songCount: number) =>
|
|
Array.from({ length: count }, (_, index) => ({
|
|
id: `album-${index}`,
|
|
name: `Album ${index}`,
|
|
songCount,
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
mockFetch.mockReset();
|
|
});
|
|
|
|
describe("NavidromeIntegration.getDashboardDataAsync", () => {
|
|
test("paginates album list and counts songs", async () => {
|
|
mockFetch.mockImplementation((url) => {
|
|
const urlStr = toUrlString(url);
|
|
|
|
if (urlStr.includes("getArtists")) {
|
|
return Promise.resolve(
|
|
new Response(subsonicOk({ artists: { index: [{ name: "A", artist: [{ id: "1", name: "Artist" }] }] } }), {
|
|
status: 200,
|
|
}),
|
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
}
|
|
|
|
if (urlStr.includes("getAlbumList2")) {
|
|
const offsetMatch = urlStr.match(/offset=(\d+)/);
|
|
const offset = Number(offsetMatch?.[1] ?? 0);
|
|
|
|
if (offset === 0) {
|
|
return Promise.resolve(
|
|
new Response(subsonicOk({ albumList2: { album: makeAlbums(500, 10) } }), { status: 200 }),
|
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
}
|
|
|
|
if (offset === 500) {
|
|
return Promise.resolve(
|
|
new Response(subsonicOk({ albumList2: { album: makeAlbums(200, 5) } }), { status: 200 }),
|
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
}
|
|
|
|
return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType<
|
|
typeof fetchWithTrustedCertificatesAsync
|
|
>;
|
|
}
|
|
|
|
return Promise.reject(new Error(`Unexpected dashboard request: ${urlStr}`)) as unknown as ReturnType<
|
|
typeof fetchWithTrustedCertificatesAsync
|
|
>;
|
|
});
|
|
|
|
const integration = createIntegration();
|
|
const result = await integration.getDashboardDataAsync();
|
|
|
|
expect(result.albumCount).toBe(700);
|
|
expect(result.songCount).toBe(500 * 10 + 200 * 5);
|
|
expect(result.artistCount).toBe(1);
|
|
});
|
|
|
|
test("handles empty library gracefully", async () => {
|
|
mockFetch.mockImplementation((url) => {
|
|
const urlStr = toUrlString(url);
|
|
|
|
if (urlStr.includes("getArtists")) {
|
|
return Promise.resolve(
|
|
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
|
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
}
|
|
|
|
if (urlStr.includes("getAlbumList2")) {
|
|
return Promise.resolve(
|
|
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
|
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
}
|
|
|
|
return Promise.reject(new Error(`Unexpected dashboard request: ${urlStr}`)) as unknown as ReturnType<
|
|
typeof fetchWithTrustedCertificatesAsync
|
|
>;
|
|
});
|
|
|
|
const integration = createIntegration();
|
|
const result = await integration.getDashboardDataAsync();
|
|
|
|
expect(result.artistCount).toBe(0);
|
|
expect(result.albumCount).toBe(0);
|
|
expect(result.songCount).toBe(0);
|
|
});
|
|
|
|
test("throws on non-empty-library subsonic error", async () => {
|
|
mockFetch.mockImplementation(
|
|
() =>
|
|
Promise.resolve(new Response(subsonicFailed("Permission denied"), { status: 200 })) as unknown as ReturnType<
|
|
typeof fetchWithTrustedCertificatesAsync
|
|
>,
|
|
);
|
|
|
|
const integration = createIntegration();
|
|
await expect(integration.getDashboardDataAsync()).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("NavidromeIntegration.getCurrentSessionsAsync", () => {
|
|
test("maps now playing entries to stream sessions", async () => {
|
|
mockFetch.mockImplementation((url) => {
|
|
const urlStr = toUrlString(url);
|
|
|
|
if (urlStr.includes("getNowPlaying")) {
|
|
return Promise.resolve(
|
|
new Response(
|
|
subsonicOk({
|
|
nowPlaying: {
|
|
entry: [
|
|
{
|
|
title: "Song",
|
|
artist: "Band",
|
|
album: "LP",
|
|
username: "user1",
|
|
playerName: "Chrome",
|
|
},
|
|
{
|
|
title: "Second Song",
|
|
artist: "Second Artist",
|
|
album: "Second Album",
|
|
username: "user2",
|
|
playerName: "Mobile",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
{ status: 200 },
|
|
),
|
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
}
|
|
|
|
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
|
|
typeof fetchWithTrustedCertificatesAsync
|
|
>;
|
|
});
|
|
|
|
const integration = createIntegration();
|
|
const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: true });
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
sessionId: "user1-Chrome",
|
|
sessionName: "Chrome",
|
|
user: {
|
|
userId: "user1",
|
|
username: "user1",
|
|
profilePictureUrl: null,
|
|
},
|
|
currentlyPlaying: {
|
|
type: "audio",
|
|
name: "Song",
|
|
seasonName: "Band",
|
|
episodeName: null,
|
|
albumName: "LP",
|
|
episodeCount: null,
|
|
playback: {
|
|
state: null,
|
|
positionMs: null,
|
|
durationMs: null,
|
|
},
|
|
location: null,
|
|
metadata: null,
|
|
},
|
|
},
|
|
{
|
|
sessionId: "user2-Mobile",
|
|
sessionName: "Mobile",
|
|
user: {
|
|
userId: "user2",
|
|
username: "user2",
|
|
profilePictureUrl: null,
|
|
},
|
|
currentlyPlaying: {
|
|
type: "audio",
|
|
name: "Second Song",
|
|
seasonName: "Second Artist",
|
|
episodeName: null,
|
|
albumName: "Second Album",
|
|
episodeCount: null,
|
|
playback: {
|
|
state: null,
|
|
positionMs: null,
|
|
durationMs: null,
|
|
},
|
|
location: null,
|
|
metadata: null,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("returns an empty array when there are no now playing entries", async () => {
|
|
mockFetch.mockImplementation((url) => {
|
|
const urlStr = toUrlString(url);
|
|
|
|
if (urlStr.includes("getNowPlaying")) {
|
|
return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType<
|
|
typeof fetchWithTrustedCertificatesAsync
|
|
>;
|
|
}
|
|
|
|
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
|
|
typeof fetchWithTrustedCertificatesAsync
|
|
>;
|
|
});
|
|
|
|
const integration = createIntegration();
|
|
const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: false });
|
|
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
test("normalizes subsonic auth failures", async () => {
|
|
mockFetch.mockImplementation((url) => {
|
|
const urlStr = toUrlString(url);
|
|
|
|
if (urlStr.includes("getNowPlaying")) {
|
|
return Promise.resolve(
|
|
new Response(subsonicFailed("Wrong username or password", 40), { status: 200 }),
|
|
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
|
}
|
|
|
|
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
|
|
typeof fetchWithTrustedCertificatesAsync
|
|
>;
|
|
});
|
|
|
|
const integration = createIntegration();
|
|
|
|
await expect(integration.getCurrentSessionsAsync({ showOnlyPlaying: true })).rejects.toMatchObject({
|
|
message: "An unknown error occured while executing Integration method",
|
|
cause: { message: "Wrong username or password" },
|
|
});
|
|
});
|
|
});
|