Files
ReadMeABook/tests/integrations/plex.service.test.ts
T
kikootwo 94dbaf073b Add backend unit test framework and modularize settings UI
Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
2026-01-28 11:41:59 -05:00

710 lines
20 KiB
TypeScript

/**
* Component: Plex Integration Service Tests
* Documentation: documentation/integrations/plex.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PlexService } from '@/lib/integrations/plex.service';
const clientMock = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}));
const axiosMock = vi.hoisted(() => ({
create: vi.fn(() => clientMock),
}));
const parseStringPromiseMock = vi.hoisted(() => vi.fn());
vi.mock('axios', () => ({
default: axiosMock,
...axiosMock,
}));
vi.mock('xml2js', () => ({
parseStringPromise: parseStringPromiseMock,
}));
describe('PlexService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('requests a PIN for OAuth', async () => {
clientMock.post.mockResolvedValue({ data: { id: 123, code: 'CODE' } });
const service = new PlexService();
const pin = await service.requestPin();
expect(pin).toEqual({ id: 123, code: 'CODE' });
});
it('throws when PIN request fails', async () => {
clientMock.post.mockRejectedValue(new Error('fail'));
const service = new PlexService();
await expect(service.requestPin()).rejects.toThrow('Failed to request authentication PIN from Plex');
});
it('returns null when PIN check fails', async () => {
clientMock.get.mockRejectedValue(new Error('fail'));
const service = new PlexService();
const token = await service.checkPin(123);
expect(token).toBeNull();
});
it('returns auth token when PIN is authorized', async () => {
clientMock.get.mockResolvedValue({ data: { authToken: 'plex-token' } });
const service = new PlexService();
const token = await service.checkPin(456);
expect(token).toBe('plex-token');
});
it('parses user info from XML responses', async () => {
clientMock.get.mockResolvedValue({ data: '<xml />' });
parseStringPromiseMock.mockResolvedValue({
user: {
$: { id: '1', username: 'user', email: 'e@example.com', thumb: '/t' },
},
});
const service = new PlexService();
const user = await service.getUserInfo('token');
expect(user).toEqual({
id: 1,
username: 'user',
email: 'e@example.com',
thumb: '/t',
authToken: 'token',
});
});
it('parses user info from JSON responses and falls back to title', async () => {
clientMock.get.mockResolvedValue({
data: { id: '2', title: 'TitleUser', email: 't@example.com', thumb: '/t' },
});
const service = new PlexService();
const user = await service.getUserInfo('token');
expect(user.username).toBe('TitleUser');
expect(user.id).toBe(2);
});
it('throws for unexpected XML user structure', async () => {
clientMock.get.mockResolvedValue({ data: '<xml />' });
parseStringPromiseMock.mockResolvedValue({ notUser: {} });
const service = new PlexService();
await expect(service.getUserInfo('token')).rejects.toThrow('Unexpected XML structure');
});
it('throws for unexpected response formats', async () => {
clientMock.get.mockResolvedValue({ data: 42 });
const service = new PlexService();
await expect(service.getUserInfo('token')).rejects.toThrow('Unexpected response format from Plex');
});
it('throws when user info is missing required fields', async () => {
clientMock.get.mockResolvedValue({ data: { username: 'user' } });
const service = new PlexService();
await expect(service.getUserInfo('token')).rejects.toThrow('User ID missing');
});
it('throws when username is missing from user info', async () => {
clientMock.get.mockResolvedValue({ data: { id: '3' } });
const service = new PlexService();
await expect(service.getUserInfo('token')).rejects.toThrow('Username missing');
});
it('returns OAuth URLs with pinId', () => {
const service = new PlexService();
const url = service.getOAuthUrl('CODE', 42, 'http://app/callback');
expect(url).toContain('CODE');
expect(url).toContain('pinId%3D42');
});
it('tests connections and parses MediaContainer responses', async () => {
clientMock.get.mockResolvedValue({
data: {
MediaContainer: {
machineIdentifier: 'machine',
version: '1.0.0',
platform: 'Plex',
},
},
});
const service = new PlexService();
const result = await service.testConnection('http://plex', 'token');
expect(result.success).toBe(true);
expect(result.info?.machineIdentifier).toBe('machine');
});
it('tests connections from XML identity responses', async () => {
clientMock.get.mockResolvedValue({ data: '<xml />' });
parseStringPromiseMock.mockResolvedValue({
MediaContainer: { $: { machineIdentifier: 'm1', version: '1.2.3', platform: 'Linux', platformVersion: '5' } },
});
const service = new PlexService();
const result = await service.testConnection('http://plex', 'token');
expect(result.success).toBe(true);
expect(result.info?.platform).toBe('Linux');
});
it('finds server access tokens in plex resources', async () => {
clientMock.get.mockResolvedValue({
data: [
{ clientIdentifier: 'machine', accessToken: 'server-token' },
],
});
const service = new PlexService();
const token = await service.getServerAccessToken('machine', 'user-token');
expect(token).toBe('server-token');
});
it('returns null when server resource is missing', async () => {
clientMock.get.mockResolvedValue({ data: [{ clientIdentifier: 'other', accessToken: 'x' }] });
const service = new PlexService();
const token = await service.getServerAccessToken('machine', 'user-token');
expect(token).toBeNull();
});
it('returns null when server access token is missing', async () => {
clientMock.get.mockResolvedValue({
data: [{ clientIdentifier: 'machine', accessToken: null }],
});
const service = new PlexService();
const token = await service.getServerAccessToken('machine', 'user-token');
expect(token).toBeNull();
});
it('verifies server access for matching resources', async () => {
clientMock.get.mockResolvedValue({
data: [
{ clientIdentifier: 'machine', provides: 'server', name: 'Plex' },
],
});
const service = new PlexService();
const hasAccess = await service.verifyServerAccess('http://plex', 'machine', 'user-token');
expect(hasAccess).toBe(true);
});
it('returns false when server access is not available', async () => {
clientMock.get.mockResolvedValue({
data: [{ clientIdentifier: 'other', provides: 'client', name: 'Plex' }],
});
const service = new PlexService();
const hasAccess = await service.verifyServerAccess('http://plex', 'machine', 'user-token');
expect(hasAccess).toBe(false);
});
it('returns false when verifying server access errors', async () => {
clientMock.get.mockRejectedValue({ response: { status: 500, data: 'oops' }, message: 'boom' });
const service = new PlexService();
const hasAccess = await service.verifyServerAccess('http://plex', 'machine', 'user-token');
expect(hasAccess).toBe(false);
});
it('parses libraries from XML responses', async () => {
clientMock.get.mockResolvedValue({ data: '<xml />' });
parseStringPromiseMock.mockResolvedValue({
MediaContainer: {
Directory: [
{
$: {
key: '1',
title: 'Books',
type: 'artist',
language: 'en',
scanner: 'scanner',
agent: 'agent',
},
Location: [{ $: { path: '/data' } }],
},
],
},
});
const service = new PlexService();
const libs = await service.getLibraries('http://plex', 'token');
expect(libs).toEqual([
{
id: '1',
title: 'Books',
type: 'artist',
language: 'en',
scanner: 'scanner',
agent: 'agent',
locations: ['/data'],
},
]);
});
it('parses libraries from JSON responses', async () => {
clientMock.get.mockResolvedValue({
data: {
MediaContainer: {
Directory: [
{
key: '2',
title: 'Library',
type: 'artist',
language: 'en',
scanner: 'scanner',
agent: 'agent',
Location: [{ path: '/media' }],
},
],
},
},
});
const service = new PlexService();
const libs = await service.getLibraries('http://plex', 'token');
expect(libs).toEqual([
{
id: '2',
title: 'Library',
type: 'artist',
language: 'en',
scanner: 'scanner',
agent: 'agent',
locations: ['/media'],
},
]);
});
it('returns null metadata for unauthorized users', async () => {
clientMock.get.mockRejectedValue({ response: { status: 401 } });
const service = new PlexService();
const meta = await service.getItemMetadata('http://plex', 'token', 'rk-1');
expect(meta).toBeNull();
});
it('returns null metadata when item is missing', async () => {
clientMock.get.mockRejectedValue({ response: { status: 404 } });
const service = new PlexService();
const meta = await service.getItemMetadata('http://plex', 'token', 'rk-2');
expect(meta).toBeNull();
});
it('parses metadata from XML responses', async () => {
clientMock.get.mockResolvedValue({ data: '<xml />' });
parseStringPromiseMock.mockResolvedValue({
MediaContainer: {
Metadata: [{ $: { userRating: '9' } }],
},
});
const service = new PlexService();
const meta = await service.getItemMetadata('http://plex', 'token', 'rk-3');
expect(meta?.userRating).toBe(9);
});
it('returns user ratings when metadata exists', async () => {
clientMock.get.mockResolvedValue({
data: { MediaContainer: { Metadata: [{ userRating: '7.5' }] } },
});
const service = new PlexService();
const meta = await service.getItemMetadata('http://plex', 'token', 'rk-1');
expect(meta?.userRating).toBe(7.5);
});
it('searches library content from XML responses', async () => {
clientMock.get.mockResolvedValue({ data: '<xml />' });
parseStringPromiseMock.mockResolvedValue({
MediaContainer: {
Metadata: [
{
$: {
ratingKey: 'rk-1',
guid: 'guid-1',
title: 'Title',
grandparentTitle: 'Author',
summary: 'Summary',
thumb: '/thumb',
addedAt: '1',
updatedAt: '2',
duration: '1000',
},
},
],
},
});
const service = new PlexService();
const results = await service.searchLibrary('http://plex', 'token', 'lib-1', 'Title');
expect(results[0].ratingKey).toBe('rk-1');
expect(results[0].author).toBe('Author');
});
it('returns empty arrays when search fails', async () => {
clientMock.get.mockRejectedValue(new Error('search fail'));
const service = new PlexService();
const results = await service.searchLibrary('http://plex', 'token', 'lib-1', 'Title');
expect(results).toEqual([]);
});
it('returns empty arrays when recently added data is not a list', async () => {
clientMock.get.mockResolvedValue({
data: { MediaContainer: { Metadata: {} } },
});
const service = new PlexService();
const results = await service.getRecentlyAdded('http://plex', 'token', 'lib-1', 10);
expect(results).toEqual([]);
});
it('returns empty arrays when library content data is not a list', async () => {
clientMock.get.mockResolvedValue({
data: { MediaContainer: { Metadata: {} } },
});
const service = new PlexService();
const results = await service.getLibraryContent('http://plex', 'token', 'lib-1');
expect(results).toEqual([]);
});
it('parses library content from XML responses', async () => {
clientMock.get.mockResolvedValue({ data: '<xml />' });
parseStringPromiseMock.mockResolvedValue({
MediaContainer: {
Metadata: [
{
ratingKey: 'rk-1',
guid: 'guid-1',
title: 'Title',
parentTitle: 'Author',
writer: 'Narr',
duration: '1000',
year: '2020',
summary: 'Summary',
thumb: '/thumb',
addedAt: '1',
updatedAt: '2',
userRating: '7',
},
],
},
});
const service = new PlexService();
const results = await service.getLibraryContent('http://plex', 'token', 'lib-1');
expect(results).toEqual([
{
ratingKey: 'rk-1',
guid: 'guid-1',
title: 'Title',
author: 'Author',
narrator: 'Narr',
duration: 1000,
year: 2020,
summary: 'Summary',
thumb: '/thumb',
addedAt: 1,
updatedAt: 2,
userRating: 7,
},
]);
});
it('throws when fetching library content fails with 401', async () => {
clientMock.get.mockRejectedValue({ response: { status: 401 } });
const service = new PlexService();
await expect(service.getLibraryContent('http://plex', 'token', 'lib-1')).rejects.toThrow(
'Failed to retrieve content from Plex library'
);
});
it('returns recently added items from JSON responses', async () => {
clientMock.get.mockResolvedValue({
data: {
MediaContainer: {
Metadata: [
{
ratingKey: 'rk-2',
guid: 'guid-2',
title: 'New Title',
parentTitle: 'Author',
writer: 'Narrator',
duration: '2000',
year: '2021',
summary: 'Summary',
thumb: '/thumb2',
addedAt: '3',
updatedAt: '4',
userRating: '8',
},
],
},
},
});
const service = new PlexService();
const results = await service.getRecentlyAdded('http://plex', 'token', 'lib-1', 5);
expect(results).toEqual([
{
ratingKey: 'rk-2',
guid: 'guid-2',
title: 'New Title',
author: 'Author',
narrator: 'Narrator',
duration: 2000,
year: 2021,
summary: 'Summary',
thumb: '/thumb2',
addedAt: 3,
updatedAt: 4,
userRating: 8,
},
]);
});
it('triggers Plex library scans', async () => {
clientMock.get.mockResolvedValue({ data: {} });
const service = new PlexService();
await expect(service.scanLibrary('http://plex', 'token', 'lib-1')).resolves.toBeUndefined();
expect(clientMock.get).toHaveBeenCalledWith(
'http://plex/library/sections/lib-1/refresh',
expect.objectContaining({
headers: { 'X-Plex-Token': 'token' },
})
);
});
it('throws when scan triggers fail', async () => {
clientMock.get.mockRejectedValue(new Error('scan failed'));
const service = new PlexService();
await expect(service.scanLibrary('http://plex', 'token', 'lib-1')).rejects.toThrow(
'Failed to trigger Plex library scan'
);
});
it('collects ratings in batch and skips failures', async () => {
const service = new PlexService();
const getItemSpy = vi.spyOn(service, 'getItemMetadata')
.mockResolvedValueOnce({ userRating: 4 })
.mockRejectedValueOnce({ response: { status: 401 } })
.mockResolvedValueOnce({ userRating: 3 });
const ratings = await service.batchGetUserRatings('http://plex', 'token', ['a', 'b', 'c']);
expect(getItemSpy).toHaveBeenCalledTimes(3);
expect(ratings.get('a')).toBe(4);
expect(ratings.get('c')).toBe(3);
expect(ratings.has('b')).toBe(false);
});
it('extracts home users from API responses', async () => {
clientMock.get.mockResolvedValue({
data: {
MediaContainer: {
User: [
{
$: {
id: '1',
uuid: 'uuid',
title: 'User',
username: 'user',
email: 'user@example.com',
thumb: '/thumb',
hasPassword: '1',
restricted: '0',
admin: '1',
guest: '0',
protected: '0',
},
},
],
},
},
});
const service = new PlexService();
const users = await service.getHomeUsers('token');
expect(users).toHaveLength(1);
expect(users[0].friendlyName).toBe('User');
expect(users[0].admin).toBe(true);
});
it('extracts home users from home.users responses', async () => {
clientMock.get.mockResolvedValue({
data: {
home: {
users: [
{
user: {
id: '2',
uuid: 'uuid-2',
title: 'Kid',
username: 'kid',
email: 'kid@example.com',
thumb: '/thumb',
hasPassword: 'true',
restricted: 'true',
admin: 'false',
guest: 'false',
protected: 'true',
},
},
],
},
},
});
const service = new PlexService();
const users = await service.getHomeUsers('token');
expect(users).toHaveLength(1);
expect(users[0].friendlyName).toBe('Kid');
expect(users[0].restricted).toBe(true);
expect(users[0].protected).toBe(true);
});
it('returns empty list when no home users are available', async () => {
clientMock.get.mockResolvedValue({ data: {} });
const service = new PlexService();
const users = await service.getHomeUsers('token');
expect(users).toEqual([]);
});
it('returns empty list when fetching home users fails', async () => {
clientMock.get.mockRejectedValue(new Error('home users down'));
const service = new PlexService();
const users = await service.getHomeUsers('token');
expect(users).toEqual([]);
});
it('switches home users and returns profile token', async () => {
clientMock.post.mockResolvedValue({ data: '<xml />' });
parseStringPromiseMock.mockResolvedValue({
user: { $: { authenticationToken: 'profile-token' } },
});
const service = new PlexService();
const token = await service.switchHomeUser('user-1', 'token');
expect(token).toBe('profile-token');
});
it('returns null when switch response has no auth token', async () => {
clientMock.post.mockResolvedValue({ data: { user: { name: 'NoToken' } } });
const service = new PlexService();
const token = await service.switchHomeUser('user-2', 'token');
expect(token).toBeNull();
});
it('returns token from direct switch responses', async () => {
clientMock.post.mockResolvedValue({ data: { authenticationToken: 'token-1' } });
const service = new PlexService();
const token = await service.switchHomeUser('user-4', 'token');
expect(token).toBe('token-1');
});
it('returns token when authenticationToken is nested under user', async () => {
clientMock.post.mockResolvedValue({ data: { user: { authenticationToken: 'token-2' } } });
const service = new PlexService();
const token = await service.switchHomeUser('user-5', 'token');
expect(token).toBe('token-2');
});
it('returns token when authenticationToken is on root attributes', async () => {
clientMock.post.mockResolvedValue({ data: { $: { authenticationToken: 'token-3' } } });
const service = new PlexService();
const token = await service.switchHomeUser('user-6', 'token');
expect(token).toBe('token-3');
});
it('throws when switching home user with invalid PIN', async () => {
clientMock.post.mockRejectedValue({ response: { status: 401 } });
const service = new PlexService();
await expect(service.switchHomeUser('user-3', 'token', '1234')).rejects.toThrow('Invalid PIN');
});
it('throws when switching home user fails for non-auth errors', async () => {
clientMock.post.mockRejectedValue({ response: { status: 500 }, message: 'boom' });
const service = new PlexService();
await expect(service.switchHomeUser('user-9', 'token')).rejects.toThrow(
'Failed to switch to selected profile'
);
});
it('returns a singleton instance from getPlexService', async () => {
const { getPlexService } = await import('@/lib/integrations/plex.service');
const serviceA = getPlexService();
const serviceB = getPlexService();
expect(serviceA).toBe(serviceB);
});
});