mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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.
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Component: Audiobookshelf API Client Tests
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
absRequest,
|
||||
getABSLibraries,
|
||||
getABSLibraryItems,
|
||||
getABSRecentItems,
|
||||
getABSServerInfo,
|
||||
searchABSItems,
|
||||
triggerABSItemMatch,
|
||||
triggerABSScan,
|
||||
} from '@/lib/services/audiobookshelf/api';
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const fetchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
describe('Audiobookshelf API client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configServiceMock.get.mockReset();
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
it('throws when Audiobookshelf config is missing', async () => {
|
||||
configServiceMock.get.mockResolvedValue(null);
|
||||
|
||||
await expect(absRequest('/status')).rejects.toThrow('Audiobookshelf not configured');
|
||||
});
|
||||
|
||||
it('returns parsed JSON for successful requests', async () => {
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'audiobookshelf.server_url') return 'http://abs';
|
||||
if (key === 'audiobookshelf.api_token') return 'token';
|
||||
return null;
|
||||
});
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: '2.0.0', name: 'ABS' }),
|
||||
});
|
||||
|
||||
const info = await getABSServerInfo();
|
||||
|
||||
expect(info).toEqual({ version: '2.0.0', name: 'ABS' });
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://abs/api/status', expect.any(Object));
|
||||
});
|
||||
|
||||
it('maps library responses and search queries', async () => {
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'audiobookshelf.server_url') return 'http://abs';
|
||||
if (key === 'audiobookshelf.api_token') return 'token';
|
||||
return null;
|
||||
});
|
||||
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ libraries: [{ id: 'lib-1' }] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ results: [{ id: 'item-1' }] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ results: [{ id: 'recent-1' }] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ book: [{ id: 'result-1' }] }),
|
||||
});
|
||||
|
||||
expect(await getABSLibraries()).toEqual([{ id: 'lib-1' }]);
|
||||
expect(await getABSLibraryItems('lib-1')).toEqual([{ id: 'item-1' }]);
|
||||
expect(await getABSRecentItems('lib-1', 5)).toEqual([{ id: 'recent-1' }]);
|
||||
expect(await searchABSItems('lib-1', 'hello world')).toEqual([{ id: 'result-1' }]);
|
||||
|
||||
expect(fetchMock.mock.calls[1][0]).toBe('http://abs/api/libraries/lib-1/items');
|
||||
expect(fetchMock.mock.calls[2][0]).toBe('http://abs/api/libraries/lib-1/items?sort=addedAt&desc=1&limit=5');
|
||||
expect(fetchMock.mock.calls[3][0]).toBe('http://abs/api/libraries/lib-1/search?q=hello%20world');
|
||||
});
|
||||
|
||||
it('triggers library scan using plain text responses', async () => {
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'audiobookshelf.server_url') return 'http://abs';
|
||||
if (key === 'audiobookshelf.api_token') return 'token';
|
||||
return null;
|
||||
});
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => 'OK',
|
||||
});
|
||||
|
||||
await triggerABSScan('lib-1');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://abs/api/libraries/lib-1/scan', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}));
|
||||
});
|
||||
|
||||
it('includes ASIN overrides in metadata match requests', async () => {
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'audiobookshelf.server_url') return 'http://abs';
|
||||
if (key === 'audiobookshelf.api_token') return 'token';
|
||||
return null;
|
||||
});
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
await triggerABSItemMatch('item-1', 'ASIN123');
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body).toEqual({
|
||||
provider: 'audible',
|
||||
asin: 'ASIN123',
|
||||
overrideDefaults: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('suppresses errors when metadata match fails', async () => {
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'audiobookshelf.server_url') return 'http://abs';
|
||||
if (key === 'audiobookshelf.api_token') return 'token';
|
||||
return null;
|
||||
});
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Boom',
|
||||
});
|
||||
|
||||
await expect(triggerABSItemMatch('item-1', 'ASIN123')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Component: Local Auth Provider Tests
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const configMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
encrypt: vi.fn((value: string) => `enc:${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace('enc:', '')),
|
||||
}));
|
||||
|
||||
const bcryptCompare = vi.fn();
|
||||
const bcryptHash = vi.fn();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
vi.mock('bcrypt', () => ({
|
||||
default: { compare: bcryptCompare, hash: bcryptHash },
|
||||
compare: bcryptCompare,
|
||||
hash: bcryptHash,
|
||||
}));
|
||||
|
||||
describe('LocalAuthProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('logs in approved local users with valid password', async () => {
|
||||
prismaMock.user.findFirst.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
role: 'user',
|
||||
authProvider: 'local',
|
||||
authToken: 'enc:hash',
|
||||
registrationStatus: 'approved',
|
||||
deletedAt: null,
|
||||
});
|
||||
prismaMock.user.update.mockResolvedValue({});
|
||||
bcryptCompare.mockResolvedValue(true);
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.handleCallback({ username: 'user', password: 'pass' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.authProvider).toBe('local');
|
||||
expect(result.tokens?.accessToken).toBeTruthy();
|
||||
expect(result.tokens?.refreshToken).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects login when credentials are missing', async () => {
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.handleCallback({ username: '', password: '' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Username and password required');
|
||||
});
|
||||
|
||||
it('blocks login when approval is pending', async () => {
|
||||
prismaMock.user.findFirst.mockResolvedValue({
|
||||
id: 'user-2',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
role: 'user',
|
||||
authProvider: 'local',
|
||||
authToken: 'enc:hash',
|
||||
registrationStatus: 'pending_approval',
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.handleCallback({ username: 'user', password: 'pass' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.requiresApproval).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects login when account is rejected', async () => {
|
||||
prismaMock.user.findFirst.mockResolvedValue({
|
||||
id: 'user-2b',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
role: 'user',
|
||||
authProvider: 'local',
|
||||
authToken: 'enc:hash',
|
||||
registrationStatus: 'rejected',
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.handleCallback({ username: 'user', password: 'pass' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('rejected');
|
||||
});
|
||||
|
||||
it('rejects login with invalid password', async () => {
|
||||
prismaMock.user.findFirst.mockResolvedValue({
|
||||
id: 'user-3',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
role: 'user',
|
||||
authProvider: 'local',
|
||||
authToken: 'enc:hash',
|
||||
registrationStatus: 'approved',
|
||||
deletedAt: null,
|
||||
});
|
||||
bcryptCompare.mockResolvedValue(false);
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.handleCallback({ username: 'user', password: 'bad' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/invalid username or password/i);
|
||||
});
|
||||
|
||||
it('rejects login when password hash cannot be decrypted', async () => {
|
||||
prismaMock.user.findFirst.mockResolvedValue({
|
||||
id: 'user-4',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
role: 'user',
|
||||
authProvider: 'local',
|
||||
authToken: 'enc:hash',
|
||||
registrationStatus: 'approved',
|
||||
deletedAt: null,
|
||||
});
|
||||
encryptionMock.decrypt.mockImplementationOnce(() => {
|
||||
throw new Error('decrypt failed');
|
||||
});
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.handleCallback({ username: 'user', password: 'pass' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/invalid username or password/i);
|
||||
});
|
||||
|
||||
it('rejects login when user is not found', async () => {
|
||||
prismaMock.user.findFirst.mockResolvedValue(null);
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.handleCallback({ username: 'user', password: 'pass' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/invalid username or password/i);
|
||||
});
|
||||
|
||||
it('blocks registration when disabled', async () => {
|
||||
configMock.get.mockResolvedValueOnce('false');
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.register({ username: 'user', password: 'password123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/registration is disabled/i);
|
||||
});
|
||||
|
||||
it('rejects short usernames or passwords on registration', async () => {
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
|
||||
let result = await provider.register({ username: 'ab', password: 'password123' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Username');
|
||||
|
||||
result = await provider.register({ username: 'user', password: 'short' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Password');
|
||||
});
|
||||
|
||||
it('rejects registration when username is taken', async () => {
|
||||
configMock.get.mockResolvedValueOnce('true');
|
||||
prismaMock.user.findFirst.mockResolvedValue({ id: 'user-10' });
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.register({ username: 'user', password: 'password123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Username already taken');
|
||||
});
|
||||
|
||||
it('creates admin user on first registration', async () => {
|
||||
configMock.get.mockResolvedValueOnce('true'); // registration enabled
|
||||
configMock.get.mockResolvedValueOnce('false'); // no admin approval
|
||||
prismaMock.user.findFirst.mockResolvedValue(null);
|
||||
prismaMock.user.count.mockResolvedValue(0);
|
||||
prismaMock.user.create.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
role: 'admin',
|
||||
});
|
||||
bcryptHash.mockResolvedValue('hash');
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.register({ username: 'user', password: 'password123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns pending approval when admin approval is required', async () => {
|
||||
configMock.get.mockResolvedValueOnce('true'); // registration enabled
|
||||
configMock.get.mockResolvedValueOnce('true'); // admin approval required
|
||||
prismaMock.user.findFirst.mockResolvedValue(null);
|
||||
prismaMock.user.count.mockResolvedValue(2);
|
||||
prismaMock.user.create.mockResolvedValue({
|
||||
id: 'user-11',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
role: 'user',
|
||||
});
|
||||
bcryptHash.mockResolvedValue('hash');
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const result = await provider.register({ username: 'user', password: 'password123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.requiresApproval).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-local or missing users during access validation', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const missing = await provider.validateAccess({ id: 'user-12', username: 'x' });
|
||||
|
||||
expect(missing).toBe(false);
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-13',
|
||||
authProvider: 'plex',
|
||||
deletedAt: null,
|
||||
registrationStatus: 'approved',
|
||||
});
|
||||
|
||||
const notLocal = await provider.validateAccess({ id: 'user-13', username: 'x' });
|
||||
expect(notLocal).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null for refresh token placeholder', async () => {
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
|
||||
const tokens = await provider.refreshToken('refresh');
|
||||
expect(tokens).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects access for deleted or unapproved users', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-4',
|
||||
authProvider: 'local',
|
||||
deletedAt: new Date(),
|
||||
registrationStatus: 'approved',
|
||||
});
|
||||
|
||||
const { LocalAuthProvider } = await import('@/lib/services/auth/LocalAuthProvider');
|
||||
const provider = new LocalAuthProvider();
|
||||
const deletedAccess = await provider.validateAccess({ id: 'user-4', username: 'x' });
|
||||
|
||||
expect(deletedAccess).toBe(false);
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'user-5',
|
||||
authProvider: 'local',
|
||||
deletedAt: null,
|
||||
registrationStatus: 'pending_approval',
|
||||
});
|
||||
|
||||
const pendingAccess = await provider.validateAccess({ id: 'user-5', username: 'x' });
|
||||
expect(pendingAccess).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Component: OIDC Auth Provider Tests
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const configMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
encrypt: vi.fn((value: string) => value),
|
||||
decrypt: vi.fn((value: string) => value),
|
||||
}));
|
||||
|
||||
const clientMock = {
|
||||
authorizationUrl: vi.fn(),
|
||||
callback: vi.fn(),
|
||||
userinfo: vi.fn(),
|
||||
};
|
||||
|
||||
const issuerMock = {
|
||||
Client: class {
|
||||
constructor() {
|
||||
return clientMock;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
const schedulerMock = vi.hoisted(() => ({
|
||||
triggerJobNow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/scheduler.service', () => ({
|
||||
getSchedulerService: () => schedulerMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/jwt', () => ({
|
||||
generateAccessToken: vi.fn(() => 'access-token'),
|
||||
generateRefreshToken: vi.fn(() => 'refresh-token'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/url', () => ({
|
||||
getBaseUrl: () => 'http://localhost:3030',
|
||||
}));
|
||||
|
||||
vi.mock('openid-client', () => ({
|
||||
Issuer: {
|
||||
discover: vi.fn(async () => issuerMock),
|
||||
},
|
||||
generators: {
|
||||
state: vi.fn(() => 'state-1'),
|
||||
nonce: vi.fn(() => 'nonce-1'),
|
||||
codeVerifier: vi.fn(() => 'verifier-1'),
|
||||
codeChallenge: vi.fn(() => 'challenge-1'),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('OIDCAuthProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.PUBLIC_URL = 'http://localhost:3030';
|
||||
});
|
||||
|
||||
const setConfig = (values: Record<string, string | null>) => {
|
||||
configMock.get.mockImplementation(async (key: string) => values[key] ?? null);
|
||||
};
|
||||
|
||||
it('returns error when code or state is missing', async () => {
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
const result = await provider.handleCallback({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/missing authorization code or state/i);
|
||||
});
|
||||
|
||||
it('returns error when provider sends an error', async () => {
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
const result = await provider.handleCallback({ error: 'access_denied' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('access_denied');
|
||||
});
|
||||
|
||||
it('returns error for invalid callback state', async () => {
|
||||
setConfig({
|
||||
'oidc.issuer_url': 'https://issuer',
|
||||
'oidc.client_id': 'client',
|
||||
'oidc.client_secret': 'secret',
|
||||
});
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
const result = await provider.handleCallback({ code: 'code', state: 'missing' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/invalid or expired state/i);
|
||||
});
|
||||
|
||||
it('initiates login and returns redirect URL with state', async () => {
|
||||
setConfig({
|
||||
'oidc.issuer_url': 'https://issuer',
|
||||
'oidc.client_id': 'client',
|
||||
'oidc.client_secret': 'secret',
|
||||
});
|
||||
|
||||
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
const result = await provider.initiateLogin();
|
||||
|
||||
expect(result.redirectUrl).toBe('https://issuer/auth');
|
||||
expect(result.state).toBe('state-1');
|
||||
});
|
||||
|
||||
it('throws when OIDC is not fully configured', async () => {
|
||||
setConfig({
|
||||
'oidc.issuer_url': null,
|
||||
'oidc.client_id': null,
|
||||
'oidc.client_secret': null,
|
||||
});
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
|
||||
await expect(provider.initiateLogin()).rejects.toThrow('Failed to initiate OIDC authentication');
|
||||
});
|
||||
|
||||
it('blocks access when group claim is missing', async () => {
|
||||
setConfig({
|
||||
'oidc.issuer_url': 'https://issuer',
|
||||
'oidc.client_id': 'client',
|
||||
'oidc.client_secret': 'secret',
|
||||
'oidc.access_control_method': 'group_claim',
|
||||
'oidc.access_group_claim': 'groups',
|
||||
'oidc.access_group_value': 'readmeabook-users',
|
||||
});
|
||||
|
||||
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
|
||||
clientMock.callback.mockResolvedValue({ access_token: 'token' });
|
||||
clientMock.userinfo.mockResolvedValue({ sub: 'sub-1', groups: ['other-group'] });
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
await provider.initiateLogin();
|
||||
const result = await provider.handleCallback({ code: 'code', state: 'state-1' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/do not have access/i);
|
||||
});
|
||||
|
||||
it('allows access for allowed list emails and returns tokens', async () => {
|
||||
setConfig({
|
||||
'oidc.issuer_url': 'https://issuer',
|
||||
'oidc.client_id': 'client',
|
||||
'oidc.client_secret': 'secret',
|
||||
'oidc.access_control_method': 'allowed_list',
|
||||
'oidc.allowed_emails': JSON.stringify(['user@example.com']),
|
||||
'oidc.allowed_usernames': JSON.stringify([]),
|
||||
});
|
||||
|
||||
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
|
||||
clientMock.callback.mockResolvedValue({ access_token: 'token' });
|
||||
clientMock.userinfo.mockResolvedValue({ sub: 'sub-3', email: 'user@example.com' });
|
||||
|
||||
prismaMock.user.count.mockResolvedValue(1);
|
||||
prismaMock.user.upsert.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexUsername: 'user@example.com',
|
||||
plexEmail: 'user@example.com',
|
||||
role: 'user',
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
await provider.initiateLogin();
|
||||
const result = await provider.handleCallback({ code: 'code', state: 'state-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tokens?.accessToken).toBe('access-token');
|
||||
});
|
||||
|
||||
it('returns requiresApproval for admin approval flow', async () => {
|
||||
setConfig({
|
||||
'oidc.issuer_url': 'https://issuer',
|
||||
'oidc.client_id': 'client',
|
||||
'oidc.client_secret': 'secret',
|
||||
'oidc.access_control_method': 'admin_approval',
|
||||
'oidc.provider_name': 'TestOIDC',
|
||||
});
|
||||
|
||||
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
|
||||
clientMock.callback.mockResolvedValue({ access_token: 'token' });
|
||||
clientMock.userinfo.mockResolvedValue({ sub: 'sub-2', preferred_username: 'user' });
|
||||
|
||||
prismaMock.user.count.mockResolvedValue(2);
|
||||
prismaMock.user.findFirst.mockResolvedValue(null);
|
||||
prismaMock.user.create.mockResolvedValue({});
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
await provider.initiateLogin();
|
||||
const result = await provider.handleCallback({ code: 'code', state: 'state-1' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.requiresApproval).toBe(true);
|
||||
});
|
||||
|
||||
it('bypasses approval for the first admin user', async () => {
|
||||
setConfig({
|
||||
'oidc.issuer_url': 'https://issuer',
|
||||
'oidc.client_id': 'client',
|
||||
'oidc.client_secret': 'secret',
|
||||
'oidc.access_control_method': 'admin_approval',
|
||||
'oidc.provider_name': 'TestOIDC',
|
||||
'oidc.admin_claim_enabled': 'true',
|
||||
'oidc.admin_claim_name': 'groups',
|
||||
'oidc.admin_claim_value': 'admins',
|
||||
});
|
||||
|
||||
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
|
||||
clientMock.callback.mockResolvedValue({ access_token: 'token' });
|
||||
clientMock.userinfo.mockResolvedValue({ sub: 'sub-4', preferred_username: 'first', groups: ['admins'] });
|
||||
|
||||
prismaMock.user.count.mockResolvedValue(0);
|
||||
prismaMock.user.findFirst.mockResolvedValue(null);
|
||||
prismaMock.user.upsert.mockResolvedValue({
|
||||
id: 'user-2',
|
||||
plexUsername: 'first',
|
||||
plexEmail: null,
|
||||
role: 'admin',
|
||||
avatarUrl: null,
|
||||
});
|
||||
prismaMock.scheduledJob.findFirst.mockResolvedValue({ id: 'sched-1' });
|
||||
prismaMock.configuration.upsert.mockResolvedValue({});
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
await provider.initiateLogin();
|
||||
const result = await provider.handleCallback({ code: 'code', state: 'state-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.isAdmin).toBe(true);
|
||||
expect(schedulerMock.triggerJobNow).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks pending and rejected users during admin approval', async () => {
|
||||
setConfig({
|
||||
'oidc.issuer_url': 'https://issuer',
|
||||
'oidc.client_id': 'client',
|
||||
'oidc.client_secret': 'secret',
|
||||
'oidc.access_control_method': 'admin_approval',
|
||||
});
|
||||
|
||||
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
|
||||
clientMock.callback.mockResolvedValue({ access_token: 'token' });
|
||||
clientMock.userinfo.mockResolvedValue({ sub: 'sub-5', preferred_username: 'pending' });
|
||||
|
||||
prismaMock.user.count.mockResolvedValue(2);
|
||||
prismaMock.user.findFirst.mockResolvedValue({ registrationStatus: 'pending_approval' });
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
await provider.initiateLogin();
|
||||
const pending = await provider.handleCallback({ code: 'code', state: 'state-1' });
|
||||
|
||||
expect(pending.success).toBe(false);
|
||||
expect(pending.requiresApproval).toBe(true);
|
||||
|
||||
prismaMock.user.findFirst.mockResolvedValue({ registrationStatus: 'rejected' });
|
||||
await provider.initiateLogin();
|
||||
const rejected = await provider.handleCallback({ code: 'code', state: 'state-1' });
|
||||
|
||||
expect(rejected.success).toBe(false);
|
||||
expect(rejected.error).toContain('rejected');
|
||||
});
|
||||
|
||||
it('returns false when access validation fails', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-3',
|
||||
authProvider: 'oidc',
|
||||
registrationStatus: 'pending_approval',
|
||||
});
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
const result = await provider.validateAccess({ id: 'user-3', username: 'user', isAdmin: false, authProvider: 'oidc' });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when access validation succeeds', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-4',
|
||||
authProvider: 'oidc',
|
||||
registrationStatus: 'approved',
|
||||
});
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
const result = await provider.validateAccess({ id: 'user-4', username: 'user', isAdmin: false, authProvider: 'oidc' });
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when access validation throws', async () => {
|
||||
prismaMock.user.findUnique.mockRejectedValue(new Error('db down'));
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
const result = await provider.validateAccess({ id: 'user-5', username: 'user', isAdmin: false, authProvider: 'oidc' });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('expires old flow states during login', async () => {
|
||||
vi.useFakeTimers();
|
||||
const start = new Date('2024-01-01T00:00:00Z');
|
||||
vi.setSystemTime(start);
|
||||
|
||||
setConfig({
|
||||
'oidc.issuer_url': 'https://issuer',
|
||||
'oidc.client_id': 'client',
|
||||
'oidc.client_secret': 'secret',
|
||||
});
|
||||
clientMock.authorizationUrl.mockReturnValue('https://issuer/auth');
|
||||
|
||||
// Make generators return different values for each call
|
||||
const { generators } = await import('openid-client');
|
||||
(generators.state as any)
|
||||
.mockReturnValueOnce('state-1')
|
||||
.mockReturnValueOnce('state-2');
|
||||
(generators.nonce as any)
|
||||
.mockReturnValueOnce('nonce-1')
|
||||
.mockReturnValueOnce('nonce-2');
|
||||
(generators.codeVerifier as any)
|
||||
.mockReturnValueOnce('verifier-1')
|
||||
.mockReturnValueOnce('verifier-2');
|
||||
(generators.codeChallenge as any)
|
||||
.mockReturnValueOnce('challenge-1')
|
||||
.mockReturnValueOnce('challenge-2');
|
||||
|
||||
const { OIDCAuthProvider } = await import('@/lib/services/auth/OIDCAuthProvider');
|
||||
const provider = new OIDCAuthProvider();
|
||||
const first = await provider.initiateLogin();
|
||||
|
||||
vi.setSystemTime(new Date(start.getTime() + 10 * 60 * 1000 + 1));
|
||||
await provider.initiateLogin();
|
||||
|
||||
const result = await provider.handleCallback({ code: 'code', state: first.state });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/Invalid or expired state/i);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Component: Plex Auth Provider Tests
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getPlexConfig: vi.fn(),
|
||||
}));
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
encrypt: vi.fn((value: string) => `enc:${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace('enc:', '')),
|
||||
}));
|
||||
const plexServiceMock = vi.hoisted(() => ({
|
||||
requestPin: vi.fn(),
|
||||
getOAuthUrl: vi.fn(),
|
||||
checkPin: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
verifyServerAccess: vi.fn(),
|
||||
getHomeUsers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/plex.service', () => ({
|
||||
getPlexService: () => plexServiceMock,
|
||||
}));
|
||||
|
||||
describe('PlexAuthProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initiates login and returns OAuth URL', async () => {
|
||||
process.env.PLEX_OAUTH_CALLBACK_URL = 'http://app/callback';
|
||||
plexServiceMock.requestPin.mockResolvedValue({ id: 42, code: 'CODE' });
|
||||
plexServiceMock.getOAuthUrl.mockReturnValue('http://plex/oauth');
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.initiateLogin();
|
||||
|
||||
expect(result.redirectUrl).toBe('http://plex/oauth');
|
||||
expect(result.pinId).toBe('42');
|
||||
});
|
||||
|
||||
it('returns error when PIN authorization is still pending', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue(null);
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.handleCallback({ pinId: '123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/waiting for user authorization/i);
|
||||
});
|
||||
|
||||
it('returns error when pinId is missing', async () => {
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.handleCallback({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/missing pin id/i);
|
||||
});
|
||||
|
||||
it('returns error when Plex server is not configured', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'user',
|
||||
authToken: 'token',
|
||||
});
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: null,
|
||||
authToken: 'token',
|
||||
libraryId: null,
|
||||
machineIdentifier: null,
|
||||
});
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.handleCallback({ pinId: '123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/plex server is not configured/i);
|
||||
});
|
||||
|
||||
it('returns profile selection when multiple home users are present', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'user',
|
||||
authToken: 'token',
|
||||
});
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
libraryId: 'lib',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
||||
plexServiceMock.getHomeUsers.mockResolvedValue([
|
||||
{ id: '1', title: 'User 1' },
|
||||
{ id: '2', title: 'User 2' },
|
||||
]);
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.handleCallback({ pinId: '123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.requiresProfileSelection).toBe(true);
|
||||
expect(result.profiles?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('denies login when server access check fails', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'user',
|
||||
authToken: 'token',
|
||||
});
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
libraryId: 'lib',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(false);
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.handleCallback({ pinId: '123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/do not have access/i);
|
||||
});
|
||||
|
||||
it('creates user and returns tokens when auth succeeds', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'user',
|
||||
email: 'user@example.com',
|
||||
thumb: 'avatar',
|
||||
authToken: 'token',
|
||||
});
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
libraryId: 'lib',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
||||
plexServiceMock.getHomeUsers.mockResolvedValue([]);
|
||||
prismaMock.user.count.mockResolvedValue(1);
|
||||
prismaMock.user.upsert.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexUsername: 'user',
|
||||
plexEmail: 'user@example.com',
|
||||
avatarUrl: 'avatar',
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const result = await provider.handleCallback({ pinId: '123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tokens?.accessToken).toBeTruthy();
|
||||
expect(result.user?.authProvider).toBe('plex');
|
||||
});
|
||||
|
||||
it('returns false when access validation has no server config', async () => {
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: null,
|
||||
machineIdentifier: null,
|
||||
});
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' });
|
||||
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when Plex auth token is missing in the database', async () => {
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', authToken: null });
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' });
|
||||
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('decrypts tokens and verifies server access', async () => {
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', authToken: 'enc:token' });
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
||||
|
||||
const { PlexAuthProvider } = await import('@/lib/services/auth/PlexAuthProvider');
|
||||
const provider = new PlexAuthProvider();
|
||||
const ok = await provider.validateAccess({ id: 'user-1', username: 'user', isAdmin: false, authProvider: 'plex' });
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:token');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Component: Configuration Service Tests
|
||||
* Documentation: documentation/backend/services/config.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
import { DEFAULT_AUDIBLE_REGION } from '@/lib/types/audible';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
encrypt: vi.fn((value: string) => `enc:${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace('enc:', '')),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
describe('ConfigurationService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('decrypts encrypted values on get', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValue({
|
||||
key: 'plex.auth_token',
|
||||
value: 'enc:secret',
|
||||
encrypted: true,
|
||||
});
|
||||
|
||||
const { ConfigurationService } = await import('@/lib/services/config.service');
|
||||
const service = new ConfigurationService();
|
||||
const value = await service.get('plex.auth_token');
|
||||
|
||||
expect(value).toBe('secret');
|
||||
expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:secret');
|
||||
});
|
||||
|
||||
it('caches values for subsequent get calls', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValue({
|
||||
key: 'system.log_level',
|
||||
value: 'info',
|
||||
encrypted: false,
|
||||
});
|
||||
|
||||
const { ConfigurationService } = await import('@/lib/services/config.service');
|
||||
const service = new ConfigurationService();
|
||||
|
||||
const first = await service.get('system.log_level');
|
||||
const second = await service.get('system.log_level');
|
||||
|
||||
expect(first).toBe('info');
|
||||
expect(second).toBe('info');
|
||||
expect(prismaMock.configuration.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('encrypts values when setting encrypted config', async () => {
|
||||
prismaMock.configuration.upsert.mockResolvedValue({});
|
||||
|
||||
const { ConfigurationService } = await import('@/lib/services/config.service');
|
||||
const service = new ConfigurationService();
|
||||
|
||||
await service.setMany([
|
||||
{ key: 'plex.auth_token', value: 'secret', encrypted: true },
|
||||
]);
|
||||
|
||||
expect(encryptionMock.encrypt).toHaveBeenCalledWith('secret');
|
||||
expect(prismaMock.configuration.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({
|
||||
value: 'enc:secret',
|
||||
encrypted: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns default Audible region when not configured', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValue(null);
|
||||
|
||||
const { ConfigurationService } = await import('@/lib/services/config.service');
|
||||
const service = new ConfigurationService();
|
||||
const region = await service.getAudibleRegion();
|
||||
|
||||
expect(region).toBe(DEFAULT_AUDIBLE_REGION);
|
||||
});
|
||||
|
||||
it('returns decrypted values for a category', async () => {
|
||||
prismaMock.configuration.findMany.mockResolvedValue([
|
||||
{
|
||||
key: 'plex.token',
|
||||
value: 'enc:secret',
|
||||
encrypted: true,
|
||||
description: 'Plex token',
|
||||
},
|
||||
]);
|
||||
|
||||
const { ConfigurationService } = await import('@/lib/services/config.service');
|
||||
const service = new ConfigurationService();
|
||||
const category = await service.getCategory('plex');
|
||||
|
||||
expect(category['plex.token'].value).toBe('secret');
|
||||
expect(category['plex.token'].encrypted).toBe(true);
|
||||
});
|
||||
|
||||
it('masks encrypted values when listing all config', async () => {
|
||||
prismaMock.configuration.findMany.mockResolvedValue([
|
||||
{
|
||||
key: 'plex.token',
|
||||
value: 'secret',
|
||||
encrypted: true,
|
||||
category: 'plex',
|
||||
description: 'Plex token',
|
||||
},
|
||||
]);
|
||||
|
||||
const { ConfigurationService } = await import('@/lib/services/config.service');
|
||||
const service = new ConfigurationService();
|
||||
const all = await service.getAll();
|
||||
|
||||
expect(all['plex.token'].value).toBe('***ENCRYPTED***');
|
||||
expect(all['plex.token'].category).toBe('plex');
|
||||
});
|
||||
|
||||
it('defaults backend mode to plex when unset', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValue(null);
|
||||
|
||||
const { ConfigurationService } = await import('@/lib/services/config.service');
|
||||
const service = new ConfigurationService();
|
||||
const mode = await service.getBackendMode();
|
||||
|
||||
expect(mode).toBe('plex');
|
||||
});
|
||||
|
||||
it('returns true when audiobookshelf mode is enabled', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValue({
|
||||
key: 'system.backend_mode',
|
||||
value: 'audiobookshelf',
|
||||
encrypted: false,
|
||||
});
|
||||
|
||||
const { ConfigurationService } = await import('@/lib/services/config.service');
|
||||
const service = new ConfigurationService();
|
||||
const enabled = await service.isAudiobookshelfMode();
|
||||
|
||||
expect(enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('builds Plex config from stored keys', async () => {
|
||||
prismaMock.configuration.findUnique.mockImplementation(async ({ where: { key } }) => {
|
||||
const values: Record<string, string> = {
|
||||
plex_url: 'http://plex',
|
||||
plex_token: 'token',
|
||||
plex_audiobook_library_id: 'lib-1',
|
||||
plex_machine_identifier: 'machine',
|
||||
};
|
||||
return values[key]
|
||||
? { key, value: values[key], encrypted: false }
|
||||
: null;
|
||||
});
|
||||
|
||||
const { ConfigurationService } = await import('@/lib/services/config.service');
|
||||
const service = new ConfigurationService();
|
||||
const plexConfig = await service.getPlexConfig();
|
||||
|
||||
expect(plexConfig).toEqual({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
libraryId: 'lib-1',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears cached entries when requested', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValue({
|
||||
key: 'system.log_level',
|
||||
value: 'info',
|
||||
encrypted: false,
|
||||
});
|
||||
|
||||
const { ConfigurationService } = await import('@/lib/services/config.service');
|
||||
const service = new ConfigurationService();
|
||||
|
||||
const first = await service.get('system.log_level');
|
||||
prismaMock.configuration.findUnique.mockResolvedValue({
|
||||
key: 'system.log_level',
|
||||
value: 'debug',
|
||||
encrypted: false,
|
||||
});
|
||||
|
||||
const cached = await service.get('system.log_level');
|
||||
service.clearCache('system.log_level');
|
||||
const updated = await service.get('system.log_level');
|
||||
|
||||
expect(first).toBe('info');
|
||||
expect(cached).toBe('info');
|
||||
expect(updated).toBe('debug');
|
||||
});
|
||||
|
||||
it('throws when setting configuration fails', async () => {
|
||||
prismaMock.configuration.upsert.mockRejectedValue(new Error('db failed'));
|
||||
|
||||
const { ConfigurationService } = await import('@/lib/services/config.service');
|
||||
const service = new ConfigurationService();
|
||||
|
||||
await expect(
|
||||
service.setMany([{ key: 'system.test', value: '1' }])
|
||||
).rejects.toThrow('db failed');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* Component: E-book Sidecar Service Tests
|
||||
* Documentation: documentation/integrations/ebook-sidecar.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import { clearMd5Cache, downloadEbook, testFlareSolverrConnection } from '@/lib/services/ebook-scraper';
|
||||
|
||||
const axiosMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
const AxiosErrorMock = vi.hoisted(() =>
|
||||
class MockAxiosError extends Error {
|
||||
code?: string;
|
||||
response?: { status?: number };
|
||||
config?: { url?: string };
|
||||
constructor(message?: string) {
|
||||
super(message);
|
||||
this.name = 'AxiosError';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
const fsCoreMock = vi.hoisted(() => ({
|
||||
createWriteStream: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
AxiosError: AxiosErrorMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
}));
|
||||
vi.mock('fs', () => fsCoreMock);
|
||||
|
||||
describe('E-book sidecar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
clearMd5Cache();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('tests FlareSolverr connections', async () => {
|
||||
const longHtml = `<html>${'Anna'.padEnd(1200, 'A')}</html>`;
|
||||
axiosMock.post.mockResolvedValue({
|
||||
data: {
|
||||
status: 'ok',
|
||||
solution: { status: 200, response: longHtml },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await testFlareSolverrConnection('http://flare');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.responseTime).toBeTypeOf('number');
|
||||
});
|
||||
|
||||
it('returns false when FlareSolverr response is invalid', async () => {
|
||||
axiosMock.post.mockResolvedValue({
|
||||
data: {
|
||||
status: 'ok',
|
||||
solution: { status: 200, response: '<html>nope</html>' },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await testFlareSolverrConnection('http://flare');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns error details when FlareSolverr request fails', async () => {
|
||||
axiosMock.post.mockRejectedValue(new Error('flare down'));
|
||||
|
||||
const result = await testFlareSolverrConnection('http://flare');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('flare down');
|
||||
});
|
||||
|
||||
it('returns errors when FlareSolverr reports failure status', async () => {
|
||||
axiosMock.post.mockResolvedValue({
|
||||
data: {
|
||||
status: 'error',
|
||||
message: 'bad',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await testFlareSolverrConnection('http://flare');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('FlareSolverr error');
|
||||
});
|
||||
|
||||
it('returns errors when FlareSolverr responds with HTTP errors', async () => {
|
||||
axiosMock.post.mockResolvedValue({
|
||||
data: {
|
||||
status: 'ok',
|
||||
solution: { status: 403, response: '<html></html>' },
|
||||
message: 'Forbidden',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await testFlareSolverrConnection('http://flare');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('FlareSolverr returned HTTP 403');
|
||||
});
|
||||
|
||||
it('downloads an ebook from ASIN search', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const writer = new EventEmitter() as EventEmitter & { close: () => void };
|
||||
writer.close = vi.fn();
|
||||
fsCoreMock.createWriteStream.mockReturnValue(writer);
|
||||
|
||||
axiosMock.get.mockImplementation(async (url: string, config?: any) => {
|
||||
if (url.includes('/search?')) {
|
||||
return { data: '<a href="/md5/abc123">Result</a>' };
|
||||
}
|
||||
if (url.includes('/md5/abc123')) {
|
||||
return { data: '<li><a href="/slow_download/abc123/0/5">Slow</a> (no waitlist)</li>' };
|
||||
}
|
||||
if (url.includes('/slow_download/')) {
|
||||
return { data: '<pre>https://files.example.com/book.epub</pre>' };
|
||||
}
|
||||
if (url === 'https://files.example.com/book.epub' && config?.responseType === 'stream') {
|
||||
return {
|
||||
data: {
|
||||
pipe: (dest: EventEmitter) => {
|
||||
setTimeout(() => dest.emit('finish'), 0);
|
||||
return dest;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const promise = downloadEbook('ASIN1', 'Title', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.format).toBe('epub');
|
||||
expect(result.filePath).toBe(path.join('/downloads', 'Title - Author.epub'));
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('falls back to title search when ASIN search has no results', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const writer = new EventEmitter() as EventEmitter & { close: () => void };
|
||||
writer.close = vi.fn();
|
||||
fsCoreMock.createWriteStream.mockReturnValue(writer);
|
||||
|
||||
axiosMock.post.mockRejectedValue(new Error('flare down'));
|
||||
axiosMock.get.mockImplementation(async (url: string, config?: any) => {
|
||||
if (url.includes('/search?') && (url.includes('asin%3A') || url.includes('asin:'))) {
|
||||
return { data: '<html></html>' };
|
||||
}
|
||||
if (url.includes('/search?') && url.includes('termtype_1=author')) {
|
||||
return { data: '<a href="/md5/abc123">Result</a>' };
|
||||
}
|
||||
if (url.includes('/md5/abc123')) {
|
||||
return { data: '<li><a href="/slow_download/abc123/0/1">Slow</a> (no waitlist)</li>' };
|
||||
}
|
||||
if (url.includes('/slow_download/')) {
|
||||
return { data: '<pre>https://files.example.com/book.pdf</pre>' };
|
||||
}
|
||||
if (url === 'https://files.example.com/book.pdf' && config?.responseType === 'stream') {
|
||||
return {
|
||||
data: {
|
||||
pipe: (dest: EventEmitter) => {
|
||||
setTimeout(() => dest.emit('finish'), 0);
|
||||
return dest;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const promise = downloadEbook('ASIN-NO', 'Title', 'Author', '/downloads', 'pdf', 'https://annas-archive.li', undefined, 'http://flare');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.format).toBe('pdf');
|
||||
expect(axiosMock.post).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns an error when no download links are available', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
axiosMock.get.mockImplementation(async (url: string) => {
|
||||
if (url.includes('/search?')) {
|
||||
return { data: '<a href="/md5/abcd12">Result</a>' };
|
||||
}
|
||||
if (url.includes('/md5/abcd12')) {
|
||||
return { data: '<li><a href="/slow_download/abcd12/0/1">Slow</a></li>' };
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const promise = downloadEbook('ASIN3', 'Missing', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No download links available');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns success when file already exists', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
|
||||
axiosMock.get.mockImplementation(async (url: string) => {
|
||||
if (url.includes('/search?')) {
|
||||
return { data: '<a href="/md5/abcdef">Result</a>' };
|
||||
}
|
||||
if (url.includes('/md5/abcdef')) {
|
||||
return { data: '<li><a href="/slow_download/abcdef/0/1">Slow</a> (no waitlist)</li>' };
|
||||
}
|
||||
if (url.includes('/slow_download/')) {
|
||||
return { data: '<pre>https://files.example.com/book.epub</pre>' };
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const promise = downloadEbook('ASIN4', 'Existing', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.filePath).toBe(path.join('/downloads', 'Existing - Author.epub'));
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns an error when downloads fail', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const writer = new EventEmitter() as EventEmitter & { close: () => void };
|
||||
writer.close = vi.fn();
|
||||
fsCoreMock.createWriteStream.mockReturnValue(writer);
|
||||
|
||||
axiosMock.get.mockImplementation(async (url: string, config?: any) => {
|
||||
if (url.includes('/search?')) {
|
||||
return { data: '<a href="/md5/deadbeef">Result</a>' };
|
||||
}
|
||||
if (url.includes('/md5/deadbeef')) {
|
||||
return { data: '<li><a href="/slow_download/deadbeef/0/1">Slow</a> (no waitlist)</li>' };
|
||||
}
|
||||
if (url.includes('/slow_download/')) {
|
||||
return { data: '<pre>https://files.example.com/book.epub</pre>' };
|
||||
}
|
||||
if (url === 'https://files.example.com/book.epub' && config?.responseType === 'stream') {
|
||||
return {
|
||||
data: {
|
||||
pipe: (dest: EventEmitter) => {
|
||||
setTimeout(() => dest.emit('error', new Error('download error')), 0);
|
||||
return dest;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const promise = downloadEbook('ASIN5', 'Fail', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('download attempts failed');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses cached ASIN search results on repeat calls', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const writer = new EventEmitter() as EventEmitter & { close: () => void };
|
||||
writer.close = vi.fn();
|
||||
fsCoreMock.createWriteStream.mockReturnValue(writer);
|
||||
|
||||
let searchCalls = 0;
|
||||
axiosMock.get.mockImplementation(async (url: string, config?: any) => {
|
||||
if (url.includes('/search?')) {
|
||||
searchCalls += 1;
|
||||
if (searchCalls > 1) {
|
||||
throw new Error('Search called twice');
|
||||
}
|
||||
return { data: '<a href="/md5/cafebabe">Result</a>' };
|
||||
}
|
||||
if (url.includes('/md5/cafebabe')) {
|
||||
return { data: '<li><a href="/slow_download/cafebabe/0/1">Slow</a> (no waitlist)</li>' };
|
||||
}
|
||||
if (url.includes('/slow_download/')) {
|
||||
return { data: '<pre>https://files.example.com/book.epub</pre>' };
|
||||
}
|
||||
if (url === 'https://files.example.com/book.epub' && config?.responseType === 'stream') {
|
||||
return {
|
||||
data: {
|
||||
pipe: (dest: EventEmitter) => {
|
||||
setTimeout(() => dest.emit('finish'), 0);
|
||||
return dest;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const first = downloadEbook('ASIN6', 'Cached', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
await first;
|
||||
|
||||
const second = downloadEbook('ASIN6', 'Cached', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await second;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(searchCalls).toBe(1);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns an error when no results are found', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
axiosMock.get.mockResolvedValue({ data: '<html></html>' });
|
||||
|
||||
const promise = downloadEbook('ASIN2', 'Missing', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No search results');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses FlareSolverr when configured for HTML fetches', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
axiosMock.post
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
status: 'ok',
|
||||
solution: { status: 200, response: '<a href="/md5/abc123">Result</a>' },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
status: 'ok',
|
||||
solution: { status: 200, response: '<html>No links</html>' },
|
||||
},
|
||||
});
|
||||
|
||||
const promise = downloadEbook(
|
||||
'ASIN7',
|
||||
'Title',
|
||||
'Author',
|
||||
'/downloads',
|
||||
'epub',
|
||||
'https://annas-archive.li',
|
||||
undefined,
|
||||
'http://flare'
|
||||
);
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No download links');
|
||||
expect(axiosMock.get).not.toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('filters ASIN search results and warns on challenge pages', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const searchHtml = `
|
||||
<div class="js-recent-downloads-container">
|
||||
<a href="/md5/abc111">Recent</a>
|
||||
</div>
|
||||
<div class="js-partial-matches-show">
|
||||
<a href="/md5/abc222">Partial</a>
|
||||
</div>
|
||||
<a href="/md5/abc333">Valid</a>
|
||||
`;
|
||||
const md5Html = '<html>challenge-running</html>';
|
||||
|
||||
axiosMock.get.mockImplementation(async (url: string) => {
|
||||
if (url.includes('/search?')) {
|
||||
return { data: searchHtml };
|
||||
}
|
||||
if (url.includes('/md5/abc333')) {
|
||||
return { data: md5Html };
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const promise = downloadEbook('ASIN8', 'Title', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No download links');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns empty slow links when md5 page fetch fails', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
axiosMock.get.mockImplementation(async (url: string) => {
|
||||
if (url.includes('/search?')) {
|
||||
return { data: '<a href="/md5/abc123">Result</a>' };
|
||||
}
|
||||
if (url.includes('/md5/abc123')) {
|
||||
throw new Error('md5 down');
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const promise = downloadEbook('ASIN9', 'Title', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No download links');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns errors when no download URL is found on slow pages', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
axiosMock.get.mockImplementation(async (url: string) => {
|
||||
if (url.includes('/search?')) {
|
||||
return { data: '<a href="/md5/abc123">Result</a>' };
|
||||
}
|
||||
if (url.includes('/md5/abc123')) {
|
||||
return { data: '<li><a href="/slow_download/abc123/0/1">Slow</a> (no waitlist)</li>' };
|
||||
}
|
||||
if (url.includes('/slow_download/abc123/0/1')) {
|
||||
return { data: '<html>No url here</html>' };
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const promise = downloadEbook('ASIN10', 'Title', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('All 1 download attempts failed');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('marks attempts failed when direct downloads fail', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
|
||||
axiosMock.get.mockImplementation(async (url: string, config?: any) => {
|
||||
if (url.includes('/search?')) {
|
||||
return { data: '<a href="/md5/abc123">Result</a>' };
|
||||
}
|
||||
if (url.includes('/md5/abc123')) {
|
||||
return { data: '<li><a href="/slow_download/abc123/0/1">Slow</a> (no waitlist)</li>' };
|
||||
}
|
||||
if (url.includes('/slow_download/abc123/0/1')) {
|
||||
return { data: '<pre>https://files.example.com/book.epub</pre>' };
|
||||
}
|
||||
if (url === 'https://files.example.com/book.epub' && config?.responseType === 'stream') {
|
||||
throw new Error('download failed');
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const promise = downloadEbook('ASIN11', 'Title', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('All 1 download attempts failed');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns errors when logger throws during download', async () => {
|
||||
const logger = {
|
||||
info: vi.fn(() => {
|
||||
throw new Error('logger boom');
|
||||
}),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const result = await downloadEbook('ASIN12', 'Title', 'Author', '/downloads', 'epub', undefined, logger as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('logger boom');
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when ASIN and title searches fail', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const error = new AxiosErrorMock('network down');
|
||||
error.code = 'ENOTFOUND';
|
||||
|
||||
axiosMock.get.mockRejectedValue(error);
|
||||
|
||||
const promise = downloadEbook('ASIN13', 'Title', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No search results found');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses cached MD5 values for title searches', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const searchHtml = `
|
||||
<div class="js-recent-downloads-container">
|
||||
<a href="/md5/recent">Recent</a>
|
||||
</div>
|
||||
<div class="js-partial-matches-show">
|
||||
<a href="/md5/partial">Partial</a>
|
||||
</div>
|
||||
<a href="/md5/cached">Valid</a>
|
||||
`;
|
||||
|
||||
axiosMock.get.mockImplementation(async (url: string) => {
|
||||
if (url.includes('/search?')) {
|
||||
return { data: searchHtml };
|
||||
}
|
||||
if (url.includes('/md5/cached')) {
|
||||
return { data: '<html></html>' };
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const first = downloadEbook('', 'Cached', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
await first;
|
||||
|
||||
const second = downloadEbook('', 'Cached', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await second;
|
||||
|
||||
const searchCalls = axiosMock.get.mock.calls.filter(([url]) => String(url).includes('/search?'));
|
||||
expect(searchCalls).toHaveLength(1);
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('downloads files when format is any and URL is in body text', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
|
||||
const writer = new EventEmitter() as EventEmitter & { close: () => void };
|
||||
writer.close = vi.fn();
|
||||
fsCoreMock.createWriteStream.mockReturnValue(writer);
|
||||
|
||||
axiosMock.get.mockImplementation(async (url: string, config?: any) => {
|
||||
if (url.includes('/search?')) {
|
||||
return { data: '<a href="/md5/deadbeef">Result</a>' };
|
||||
}
|
||||
if (url.includes('/md5/deadbeef')) {
|
||||
return { data: '<li><a href="/slow_download/deadbeef/0/1">Slow</a> (no waitlist)</li>' };
|
||||
}
|
||||
if (url.includes('/slow_download/deadbeef/0/1')) {
|
||||
return { data: '<body>https://files.example.com/book.pdf</body>' };
|
||||
}
|
||||
if (url === 'https://files.example.com/book.pdf' && config?.responseType === 'stream') {
|
||||
return {
|
||||
data: {
|
||||
pipe: (dest: EventEmitter) => {
|
||||
setTimeout(() => dest.emit('finish'), 0);
|
||||
return dest;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const promise = downloadEbook('ASIN14', 'Any', 'Author', '/downloads', 'any');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.format).toBe('pdf');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('times out downloads that never finish', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
|
||||
const writer = new EventEmitter() as EventEmitter & { close: () => void };
|
||||
writer.close = vi.fn();
|
||||
fsCoreMock.createWriteStream.mockReturnValue(writer);
|
||||
|
||||
axiosMock.get.mockImplementation(async (url: string, config?: any) => {
|
||||
if (url.includes('/search?')) {
|
||||
return { data: '<a href="/md5/abc999">Result</a>' };
|
||||
}
|
||||
if (url.includes('/md5/abc999')) {
|
||||
return { data: '<li><a href="/slow_download/abc999/0/1">Slow</a> (no waitlist)</li>' };
|
||||
}
|
||||
if (url.includes('/slow_download/abc999/0/1')) {
|
||||
return { data: '<pre>https://files.example.com/book.epub</pre>' };
|
||||
}
|
||||
if (url === 'https://files.example.com/book.epub' && config?.responseType === 'stream') {
|
||||
return {
|
||||
data: {
|
||||
pipe: (dest: EventEmitter) => dest,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
});
|
||||
|
||||
const promise = downloadEbook('ASIN15', 'Title', 'Author', '/downloads');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('All 1 download attempts failed');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Component: Encryption Service Tests
|
||||
* Documentation: documentation/backend/services/config.md
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const ORIGINAL_KEY = process.env.CONFIG_ENCRYPTION_KEY;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.CONFIG_ENCRYPTION_KEY = ORIGINAL_KEY;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('EncryptionService', () => {
|
||||
it('throws when encryption key is missing', async () => {
|
||||
delete process.env.CONFIG_ENCRYPTION_KEY;
|
||||
vi.resetModules();
|
||||
|
||||
const { EncryptionService } = await import('@/lib/services/encryption.service');
|
||||
expect(() => new EncryptionService()).toThrow(/CONFIG_ENCRYPTION_KEY/);
|
||||
});
|
||||
|
||||
it('encrypts and decrypts values', async () => {
|
||||
process.env.CONFIG_ENCRYPTION_KEY = 'a'.repeat(32);
|
||||
vi.resetModules();
|
||||
|
||||
const { EncryptionService } = await import('@/lib/services/encryption.service');
|
||||
const service = new EncryptionService();
|
||||
|
||||
const encrypted = service.encrypt('secret');
|
||||
const decrypted = service.decrypt(encrypted);
|
||||
|
||||
expect(decrypted).toBe('secret');
|
||||
});
|
||||
|
||||
it('rejects invalid encrypted data formats', async () => {
|
||||
process.env.CONFIG_ENCRYPTION_KEY = 'b'.repeat(32);
|
||||
vi.resetModules();
|
||||
|
||||
const { EncryptionService } = await import('@/lib/services/encryption.service');
|
||||
const service = new EncryptionService();
|
||||
|
||||
expect(() => service.decrypt('invalid')).toThrow(/Decryption failed/);
|
||||
});
|
||||
|
||||
it('generates a random key', async () => {
|
||||
const { EncryptionService } = await import('@/lib/services/encryption.service');
|
||||
const key = EncryptionService.generateKey();
|
||||
|
||||
expect(typeof key).toBe('string');
|
||||
expect(key.length).toBeGreaterThan(40);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,624 @@
|
||||
/**
|
||||
* Component: Job Queue Service Tests
|
||||
* Documentation: documentation/backend/services/jobs.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
const processorsMock = vi.hoisted(() => ({
|
||||
processSearchIndexers: vi.fn().mockResolvedValue('ok'),
|
||||
processDownloadTorrent: vi.fn().mockResolvedValue('ok'),
|
||||
processMonitorDownload: vi.fn().mockResolvedValue('ok'),
|
||||
processOrganizeFiles: vi.fn().mockResolvedValue('ok'),
|
||||
processScanPlex: vi.fn().mockResolvedValue('ok'),
|
||||
processMatchPlex: vi.fn().mockResolvedValue('ok'),
|
||||
processPlexRecentlyAddedCheck: vi.fn().mockResolvedValue('ok'),
|
||||
processMonitorRssFeeds: vi.fn().mockResolvedValue('ok'),
|
||||
processAudibleRefresh: vi.fn().mockResolvedValue('ok'),
|
||||
processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'),
|
||||
processRetryFailedImports: vi.fn().mockResolvedValue('ok'),
|
||||
processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'),
|
||||
}));
|
||||
|
||||
const queueMock = vi.hoisted(() => ({
|
||||
on: vi.fn(),
|
||||
process: vi.fn(),
|
||||
add: vi.fn(),
|
||||
getJobCounts: vi.fn(),
|
||||
getActive: vi.fn(),
|
||||
getJob: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
close: vi.fn(),
|
||||
removeRepeatable: vi.fn(),
|
||||
getRepeatableJobs: vi.fn(),
|
||||
setMaxListeners: vi.fn(),
|
||||
}));
|
||||
|
||||
const redisMock = vi.hoisted(() => ({
|
||||
setMaxListeners: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
const QueueConstructor = vi.hoisted(() =>
|
||||
vi.fn(function Queue() {
|
||||
return queueMock;
|
||||
})
|
||||
);
|
||||
|
||||
const RedisConstructor = vi.hoisted(() =>
|
||||
vi.fn(function Redis() {
|
||||
return redisMock;
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock('bull', () => ({
|
||||
default: QueueConstructor,
|
||||
}));
|
||||
|
||||
vi.mock('ioredis', () => ({
|
||||
default: RedisConstructor,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/search-indexers.processor', () => ({
|
||||
processSearchIndexers: processorsMock.processSearchIndexers,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/download-torrent.processor', () => ({
|
||||
processDownloadTorrent: processorsMock.processDownloadTorrent,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/monitor-download.processor', () => ({
|
||||
processMonitorDownload: processorsMock.processMonitorDownload,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/organize-files.processor', () => ({
|
||||
processOrganizeFiles: processorsMock.processOrganizeFiles,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/scan-plex.processor', () => ({
|
||||
processScanPlex: processorsMock.processScanPlex,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/match-plex.processor', () => ({
|
||||
processMatchPlex: processorsMock.processMatchPlex,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/plex-recently-added.processor', () => ({
|
||||
processPlexRecentlyAddedCheck: processorsMock.processPlexRecentlyAddedCheck,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/monitor-rss-feeds.processor', () => ({
|
||||
processMonitorRssFeeds: processorsMock.processMonitorRssFeeds,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/audible-refresh.processor', () => ({
|
||||
processAudibleRefresh: processorsMock.processAudibleRefresh,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/retry-missing-torrents.processor', () => ({
|
||||
processRetryMissingTorrents: processorsMock.processRetryMissingTorrents,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/retry-failed-imports.processor', () => ({
|
||||
processRetryFailedImports: processorsMock.processRetryFailedImports,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({
|
||||
processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
describe('JobQueueService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queueMock.add.mockReset();
|
||||
queueMock.getJobCounts.mockReset();
|
||||
queueMock.getJob.mockReset();
|
||||
queueMock.getActive.mockReset();
|
||||
queueMock.process.mockReset();
|
||||
queueMock.on.mockReset();
|
||||
queueMock.getRepeatableJobs.mockReset();
|
||||
prismaMock.job.create.mockReset();
|
||||
prismaMock.job.update.mockReset();
|
||||
prismaMock.job.updateMany.mockReset();
|
||||
prismaMock.job.findUnique.mockReset();
|
||||
prismaMock.job.findFirst.mockReset();
|
||||
prismaMock.job.findMany.mockReset();
|
||||
prismaMock.scheduledJob.update.mockReset();
|
||||
prismaMock.request.update.mockReset();
|
||||
prismaMock.downloadHistory.update.mockReset();
|
||||
});
|
||||
|
||||
it('adds search jobs with priority and stores Bull job ID', async () => {
|
||||
prismaMock.job.create.mockResolvedValue({ id: 'job-1' });
|
||||
queueMock.add.mockResolvedValue({ id: 'bull-1' });
|
||||
prismaMock.job.update.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
const jobId = await service.addSearchJob('req-1', {
|
||||
id: 'ab-1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
asin: 'ASIN1',
|
||||
});
|
||||
|
||||
expect(jobId).toBe('job-1');
|
||||
expect(prismaMock.job.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
requestId: 'req-1',
|
||||
type: 'search_indexers',
|
||||
priority: 10,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(queueMock.add).toHaveBeenCalledWith(
|
||||
'search_indexers',
|
||||
expect.objectContaining({ jobId: 'job-1', requestId: 'req-1' }),
|
||||
expect.objectContaining({ priority: 10 })
|
||||
);
|
||||
expect(prismaMock.job.update).toHaveBeenCalledWith({
|
||||
where: { id: 'job-1' },
|
||||
data: { bullJobId: 'bull-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds download jobs with expected priority', async () => {
|
||||
prismaMock.job.create.mockResolvedValue({ id: 'job-2' });
|
||||
queueMock.add.mockResolvedValue({ id: 'bull-2' });
|
||||
prismaMock.job.update.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
await service.addDownloadJob('req-1', { id: 'ab-1', title: 'Title', author: 'Author' }, { hash: 'hash' } as any);
|
||||
|
||||
expect(queueMock.add).toHaveBeenCalledWith(
|
||||
'download_torrent',
|
||||
expect.objectContaining({ requestId: 'req-1', jobId: 'job-2' }),
|
||||
expect.objectContaining({ priority: 9 })
|
||||
);
|
||||
});
|
||||
|
||||
it('adds monitor jobs with delay in milliseconds', async () => {
|
||||
prismaMock.job.create.mockResolvedValue({ id: 'job-3' });
|
||||
queueMock.add.mockResolvedValue({ id: 'bull-3' });
|
||||
prismaMock.job.update.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
await service.addMonitorJob('req-2', 'hist-1', 'client-1', 'qbittorrent', 15);
|
||||
|
||||
expect(queueMock.add).toHaveBeenCalledWith(
|
||||
'monitor_download',
|
||||
expect.objectContaining({ requestId: 'req-2', jobId: 'job-3' }),
|
||||
expect.objectContaining({ priority: 5, delay: 15000 })
|
||||
);
|
||||
});
|
||||
|
||||
it('adds organize jobs with target path payload', async () => {
|
||||
prismaMock.job.create.mockResolvedValue({ id: 'job-4' });
|
||||
queueMock.add.mockResolvedValue({ id: 'bull-4' });
|
||||
prismaMock.job.update.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
await service.addOrganizeJob('req-3', 'ab-3', '/downloads/book', '/media/book');
|
||||
|
||||
expect(queueMock.add).toHaveBeenCalledWith(
|
||||
'organize_files',
|
||||
expect.objectContaining({ requestId: 'req-3', targetPath: '/media/book', jobId: 'job-4' }),
|
||||
expect.objectContaining({ priority: 8 })
|
||||
);
|
||||
});
|
||||
|
||||
it('adds plex and scheduled jobs with expected priorities', async () => {
|
||||
const jobIds = ['job-5', 'job-6', 'job-7', 'job-8', 'job-9', 'job-10', 'job-11', 'job-12'];
|
||||
jobIds.forEach((id) => prismaMock.job.create.mockResolvedValueOnce({ id }));
|
||||
queueMock.add.mockResolvedValue({ id: 'bull' });
|
||||
prismaMock.job.update.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
|
||||
await service.addPlexScanJob('lib-1', true, '/path');
|
||||
await service.addPlexMatchJob('req-1', 'ab-1', 'Title', 'Author');
|
||||
await service.addPlexRecentlyAddedJob('sched-1');
|
||||
await service.addMonitorRssFeedsJob('sched-2');
|
||||
await service.addAudibleRefreshJob('sched-3');
|
||||
await service.addRetryMissingTorrentsJob('sched-4');
|
||||
await service.addRetryFailedImportsJob('sched-5');
|
||||
await service.addCleanupSeededTorrentsJob('sched-6');
|
||||
|
||||
expect(queueMock.add.mock.calls[0][0]).toBe('scan_plex');
|
||||
expect(queueMock.add.mock.calls[0][2].priority).toBe(7);
|
||||
expect(queueMock.add.mock.calls[0][1]).toEqual(expect.objectContaining({ libraryId: 'lib-1', partial: true, path: '/path' }));
|
||||
|
||||
expect(queueMock.add.mock.calls[1][0]).toBe('match_plex');
|
||||
expect(queueMock.add.mock.calls[1][2].priority).toBe(6);
|
||||
|
||||
expect(queueMock.add.mock.calls[2][0]).toBe('plex_recently_added_check');
|
||||
expect(queueMock.add.mock.calls[2][2].priority).toBe(8);
|
||||
|
||||
expect(queueMock.add.mock.calls[3][0]).toBe('monitor_rss_feeds');
|
||||
expect(queueMock.add.mock.calls[3][2].priority).toBe(8);
|
||||
|
||||
expect(queueMock.add.mock.calls[4][0]).toBe('audible_refresh');
|
||||
expect(queueMock.add.mock.calls[4][2].priority).toBe(9);
|
||||
|
||||
expect(queueMock.add.mock.calls[5][0]).toBe('retry_missing_torrents');
|
||||
expect(queueMock.add.mock.calls[5][2].priority).toBe(7);
|
||||
|
||||
expect(queueMock.add.mock.calls[6][0]).toBe('retry_failed_imports');
|
||||
expect(queueMock.add.mock.calls[6][2].priority).toBe(7);
|
||||
|
||||
expect(queueMock.add.mock.calls[7][0]).toBe('cleanup_seeded_torrents');
|
||||
expect(queueMock.add.mock.calls[7][2].priority).toBe(10);
|
||||
});
|
||||
|
||||
it('returns queue stats with safe defaults', async () => {
|
||||
queueMock.getJobCounts.mockResolvedValue({ waiting: 2, active: 1 });
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
const stats = await service.getQueueStats();
|
||||
|
||||
expect(stats).toEqual({
|
||||
waiting: 2,
|
||||
active: 1,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
delayed: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a single job by ID', async () => {
|
||||
prismaMock.job.findUnique.mockResolvedValue({ id: 'job-10' });
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
const job = await service.getJob('job-10');
|
||||
|
||||
expect(prismaMock.job.findUnique).toHaveBeenCalledWith({ where: { id: 'job-10' } });
|
||||
expect(job).toEqual({ id: 'job-10' });
|
||||
});
|
||||
|
||||
it('returns jobs for a request ordered by createdAt', async () => {
|
||||
prismaMock.job.findMany.mockResolvedValue([{ id: 'job-11' }]);
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
const jobs = await service.getJobsByRequest('req-10');
|
||||
|
||||
expect(prismaMock.job.findMany).toHaveBeenCalledWith({
|
||||
where: { requestId: 'req-10' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
expect(jobs).toEqual([{ id: 'job-11' }]);
|
||||
});
|
||||
|
||||
it('retries a failed job and resets metadata', async () => {
|
||||
prismaMock.job.findUnique.mockResolvedValue({ id: 'job-1', bullJobId: 'bull-1' });
|
||||
queueMock.getJob.mockResolvedValue({ retry: vi.fn() });
|
||||
prismaMock.job.update.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
await service.retryJob('job-1');
|
||||
|
||||
expect(queueMock.getJob).toHaveBeenCalledWith('bull-1');
|
||||
expect(prismaMock.job.update).toHaveBeenCalledWith({
|
||||
where: { id: 'job-1' },
|
||||
data: {
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
errorMessage: null,
|
||||
stackTrace: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when retrying an unknown job', async () => {
|
||||
prismaMock.job.findUnique.mockResolvedValue(null);
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
|
||||
await expect(service.retryJob('missing')).rejects.toThrow('Job not found');
|
||||
});
|
||||
|
||||
it('cancels jobs and removes Bull entry', async () => {
|
||||
prismaMock.job.findUnique.mockResolvedValue({ id: 'job-2', bullJobId: 'bull-2' });
|
||||
queueMock.getJob.mockResolvedValue({ remove: vi.fn() });
|
||||
prismaMock.job.update.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
await service.cancelJob('job-2');
|
||||
|
||||
expect(queueMock.getJob).toHaveBeenCalledWith('bull-2');
|
||||
expect(prismaMock.job.update).toHaveBeenCalledWith({
|
||||
where: { id: 'job-2' },
|
||||
data: { status: 'cancelled' },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds and removes repeatable jobs', async () => {
|
||||
queueMock.add.mockResolvedValue({});
|
||||
queueMock.removeRepeatable.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
await service.addRepeatableJob('audible_refresh', { scheduledJobId: 'sched-1' }, '0 0 * * *', 'scheduled-1');
|
||||
await service.removeRepeatableJob('audible_refresh', '0 0 * * *', 'scheduled-1');
|
||||
|
||||
expect(queueMock.add).toHaveBeenCalledWith(
|
||||
'audible_refresh',
|
||||
{ scheduledJobId: 'sched-1' },
|
||||
{ repeat: { cron: '0 0 * * *' }, jobId: 'scheduled-1' }
|
||||
);
|
||||
expect(queueMock.removeRepeatable).toHaveBeenCalledWith('audible_refresh', {
|
||||
cron: '0 0 * * *',
|
||||
jobId: 'scheduled-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates job records for timer-triggered jobs', async () => {
|
||||
prismaMock.job.findFirst.mockResolvedValue(null);
|
||||
prismaMock.job.create.mockResolvedValue({ id: 'job-3' });
|
||||
prismaMock.scheduledJob.update.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
const payload = await (service as any).ensureJobRecord(
|
||||
{ id: 'bull-3', data: { scheduledJobId: 'sched-3' } },
|
||||
'audible_refresh'
|
||||
);
|
||||
|
||||
expect(prismaMock.job.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
bullJobId: 'bull-3',
|
||||
type: 'audible_refresh',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(prismaMock.scheduledJob.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sched-3' },
|
||||
data: { lastRun: expect.any(Date) },
|
||||
});
|
||||
expect(payload.jobId).toBe('job-3');
|
||||
});
|
||||
|
||||
it('returns existing job IDs for scheduled jobs already in the database', async () => {
|
||||
prismaMock.job.findFirst.mockResolvedValue({ id: 'job-4' });
|
||||
prismaMock.scheduledJob.update.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
const payload = await (service as any).ensureJobRecord(
|
||||
{ id: 'bull-4', data: { scheduledJobId: 'sched-4' } },
|
||||
'cleanup_seeded_torrents'
|
||||
);
|
||||
|
||||
expect(payload.jobId).toBe('job-4');
|
||||
expect(prismaMock.scheduledJob.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sched-4' },
|
||||
data: { lastRun: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns payload unchanged when jobId already exists', async () => {
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
const payload = await (service as any).ensureJobRecord(
|
||||
{ id: 'bull-5', data: { jobId: 'job-5' } },
|
||||
'audible_refresh'
|
||||
);
|
||||
|
||||
expect(payload.jobId).toBe('job-5');
|
||||
expect(prismaMock.job.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates job metadata on lifecycle events', async () => {
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
const updateSpy = vi.spyOn(service as any, 'updateJobInDatabase').mockResolvedValue(undefined);
|
||||
|
||||
const handlers = Object.fromEntries(queueMock.on.mock.calls.map(([event, handler]) => [event, handler]));
|
||||
|
||||
await handlers.active({ id: 'bull-10' });
|
||||
await handlers.completed({ id: 'bull-10' }, { ok: true });
|
||||
await handlers.stalled({ id: 'bull-10' });
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith('bull-10', 'active');
|
||||
expect(updateSpy).toHaveBeenCalledWith('bull-10', 'completed', { ok: true });
|
||||
expect(updateSpy).toHaveBeenCalledWith('bull-10', 'stuck');
|
||||
});
|
||||
|
||||
it('marks monitor download failures and updates request status', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
new JobQueueService();
|
||||
|
||||
const handlers = Object.fromEntries(queueMock.on.mock.calls.map(([event, handler]) => [event, handler]));
|
||||
await handlers.failed(
|
||||
{
|
||||
id: 'bull-11',
|
||||
name: 'monitor_download',
|
||||
data: { requestId: 'req-1', downloadHistoryId: 'hist-1' },
|
||||
attemptsMade: 3,
|
||||
},
|
||||
new Error('Monitor failed')
|
||||
);
|
||||
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'req-1' },
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
})
|
||||
);
|
||||
expect(prismaMock.downloadHistory.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'hist-1' },
|
||||
data: expect.objectContaining({ downloadStatus: 'failed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('updates database fields for completed jobs', async () => {
|
||||
prismaMock.job.updateMany.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
await (service as any).updateJobInDatabase('bull-12', 'completed', { result: true }, 'err', 'stack');
|
||||
|
||||
expect(prismaMock.job.updateMany).toHaveBeenCalledWith({
|
||||
where: { bullJobId: 'bull-12' },
|
||||
data: expect.objectContaining({
|
||||
status: 'completed',
|
||||
result: { result: true },
|
||||
errorMessage: 'err',
|
||||
stackTrace: 'stack',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('sets startedAt when jobs become active', async () => {
|
||||
prismaMock.job.updateMany.mockResolvedValue({});
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
await (service as any).updateJobInDatabase('bull-13', 'active');
|
||||
|
||||
expect(prismaMock.job.updateMany).toHaveBeenCalledWith({
|
||||
where: { bullJobId: 'bull-13' },
|
||||
data: expect.objectContaining({
|
||||
status: 'active',
|
||||
startedAt: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('swallows database errors when updating job status', async () => {
|
||||
prismaMock.job.updateMany.mockRejectedValue(new Error('db down'));
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
|
||||
await expect((service as any).updateJobInDatabase('bull-14', 'completed')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('registers processors for supported job types', async () => {
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
new JobQueueService();
|
||||
|
||||
const jobTypes = queueMock.process.mock.calls.map(([type]) => type);
|
||||
expect(jobTypes).toContain('search_indexers');
|
||||
expect(jobTypes).toContain('download_torrent');
|
||||
expect(jobTypes).toContain('monitor_download');
|
||||
expect(jobTypes).toContain('audible_refresh');
|
||||
});
|
||||
|
||||
it('invokes processor handlers for registered jobs', async () => {
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
new JobQueueService();
|
||||
|
||||
const handlers = queueMock.process.mock.calls.map((call) => call[2] || call[1]);
|
||||
for (const handler of handlers) {
|
||||
await handler({ id: 'bull-processor', data: { jobId: 'job-processor', scheduledJobId: 'sched-1' } });
|
||||
}
|
||||
|
||||
expect(processorsMock.processSearchIndexers).toHaveBeenCalled();
|
||||
expect(processorsMock.processDownloadTorrent).toHaveBeenCalled();
|
||||
expect(processorsMock.processMonitorDownload).toHaveBeenCalled();
|
||||
expect(processorsMock.processOrganizeFiles).toHaveBeenCalled();
|
||||
expect(processorsMock.processScanPlex).toHaveBeenCalled();
|
||||
expect(processorsMock.processMatchPlex).toHaveBeenCalled();
|
||||
expect(processorsMock.processPlexRecentlyAddedCheck).toHaveBeenCalled();
|
||||
expect(processorsMock.processMonitorRssFeeds).toHaveBeenCalled();
|
||||
expect(processorsMock.processAudibleRefresh).toHaveBeenCalled();
|
||||
expect(processorsMock.processRetryMissingTorrents).toHaveBeenCalled();
|
||||
expect(processorsMock.processRetryFailedImports).toHaveBeenCalled();
|
||||
expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns repeatable jobs from the queue', async () => {
|
||||
queueMock.getRepeatableJobs.mockResolvedValue([{ key: 'job-1' }]);
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
const jobs = await service.getRepeatableJobs();
|
||||
|
||||
expect(queueMock.getRepeatableJobs).toHaveBeenCalled();
|
||||
expect(jobs).toEqual([{ key: 'job-1' }]);
|
||||
});
|
||||
|
||||
it('returns active jobs from prisma using Bull job IDs', async () => {
|
||||
queueMock.getActive.mockResolvedValue([{ id: 'bull-20' }, { id: 'bull-21' }]);
|
||||
prismaMock.job.findMany.mockResolvedValue([{ id: 'job-20' }]);
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
const jobs = await service.getActiveJobs();
|
||||
|
||||
expect(prismaMock.job.findMany).toHaveBeenCalledWith({
|
||||
where: { bullJobId: { in: ['bull-20', 'bull-21'] } },
|
||||
});
|
||||
expect(jobs).toEqual([{ id: 'job-20' }]);
|
||||
});
|
||||
|
||||
it('returns failed jobs with limit', async () => {
|
||||
prismaMock.job.findMany.mockResolvedValue([{ id: 'job-30' }]);
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
const jobs = await service.getFailedJobs(10);
|
||||
|
||||
expect(prismaMock.job.findMany).toHaveBeenCalledWith({
|
||||
where: { status: 'failed' },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
expect(jobs).toEqual([{ id: 'job-30' }]);
|
||||
});
|
||||
|
||||
it('throws when cancelling unknown jobs', async () => {
|
||||
prismaMock.job.findUnique.mockResolvedValue(null);
|
||||
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
|
||||
await expect(service.cancelJob('missing')).rejects.toThrow('Job not found');
|
||||
});
|
||||
|
||||
it('pauses and resumes the queue', async () => {
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
|
||||
await service.pauseQueue();
|
||||
await service.resumeQueue();
|
||||
|
||||
expect(queueMock.pause).toHaveBeenCalled();
|
||||
expect(queueMock.resume).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the queue and disconnects redis', async () => {
|
||||
const { JobQueueService } = await import('@/lib/services/job-queue.service');
|
||||
const service = new JobQueueService();
|
||||
|
||||
await service.close();
|
||||
|
||||
expect(queueMock.close).toHaveBeenCalled();
|
||||
expect(redisMock.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Component: Audiobookshelf Library Service Tests
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AudiobookshelfLibraryService } from '@/lib/services/library/AudiobookshelfLibraryService';
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
getABSServerInfo: vi.fn(),
|
||||
getABSLibraries: vi.fn(),
|
||||
getABSLibraryItems: vi.fn(),
|
||||
getABSRecentItems: vi.fn(),
|
||||
getABSItem: vi.fn(),
|
||||
searchABSItems: vi.fn(),
|
||||
triggerABSScan: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/audiobookshelf/api', () => apiMock);
|
||||
|
||||
describe('AudiobookshelfLibraryService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('tests connection and returns server info', async () => {
|
||||
apiMock.getABSServerInfo.mockResolvedValue({ name: 'ABS', version: '2.0.0' });
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.serverInfo).toEqual({
|
||||
name: 'ABS',
|
||||
version: '2.0.0',
|
||||
identifier: 'ABS',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns errors when server info fails', async () => {
|
||||
apiMock.getABSServerInfo.mockRejectedValue(new Error('No connection'));
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('No connection');
|
||||
});
|
||||
|
||||
it('filters audiobook libraries only', async () => {
|
||||
apiMock.getABSLibraries.mockResolvedValue([
|
||||
{ id: 'lib-1', name: 'Books', mediaType: 'book', stats: { totalItems: 10 } },
|
||||
{ id: 'lib-2', name: 'Podcasts', mediaType: 'podcast', stats: { totalItems: 5 } },
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const libs = await service.getLibraries();
|
||||
|
||||
expect(libs).toEqual([
|
||||
{ id: 'lib-1', name: 'Books', type: 'book', itemCount: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps library items to generic fields', async () => {
|
||||
apiMock.getABSLibraryItems.mockResolvedValue([
|
||||
{
|
||||
id: 'item-1',
|
||||
addedAt: 1700000000000,
|
||||
updatedAt: 1700000100000,
|
||||
media: {
|
||||
duration: 3600,
|
||||
coverPath: '/covers/1.jpg',
|
||||
metadata: {
|
||||
title: 'Title',
|
||||
authorName: 'Author',
|
||||
narratorName: 'Narrator',
|
||||
description: 'Desc',
|
||||
asin: 'ASIN1',
|
||||
isbn: 'ISBN1',
|
||||
publishedYear: '2020',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.getLibraryItems('lib-1');
|
||||
|
||||
expect(items[0]).toEqual({
|
||||
id: 'item-1',
|
||||
externalId: 'item-1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
narrator: 'Narrator',
|
||||
description: 'Desc',
|
||||
coverUrl: '/api/items/item-1/cover',
|
||||
duration: 3600,
|
||||
asin: 'ASIN1',
|
||||
isbn: 'ISBN1',
|
||||
year: 2020,
|
||||
addedAt: new Date(1700000000000),
|
||||
updatedAt: new Date(1700000100000),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when item fetch fails', async () => {
|
||||
apiMock.getABSItem.mockRejectedValue(new Error('missing'));
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const result = await service.getItem('item-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('searches items and maps results', async () => {
|
||||
apiMock.searchABSItems.mockResolvedValue([
|
||||
{
|
||||
libraryItem: {
|
||||
id: 'item-2',
|
||||
addedAt: 1700000000000,
|
||||
updatedAt: 1700000000000,
|
||||
media: {
|
||||
duration: 200,
|
||||
metadata: {
|
||||
title: 'Search Title',
|
||||
authorName: 'Search Author',
|
||||
narratorName: '',
|
||||
description: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
const items = await service.searchItems('lib-1', 'Search');
|
||||
|
||||
expect(items[0].title).toBe('Search Title');
|
||||
expect(items[0].author).toBe('Search Author');
|
||||
});
|
||||
|
||||
it('triggers library scans', async () => {
|
||||
apiMock.triggerABSScan.mockResolvedValue(undefined);
|
||||
|
||||
const service = new AudiobookshelfLibraryService();
|
||||
await service.triggerLibraryScan('lib-1');
|
||||
|
||||
expect(apiMock.triggerABSScan).toHaveBeenCalledWith('lib-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Component: Library Service Factory Tests
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { clearLibraryServiceCache, getLibraryService } from '@/lib/services/library';
|
||||
|
||||
const MockPlexService = vi.hoisted(() => class MockPlexService {});
|
||||
const MockAbsService = vi.hoisted(() => class MockAbsService {});
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/library/PlexLibraryService', () => ({
|
||||
PlexLibraryService: MockPlexService,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/library/AudiobookshelfLibraryService', () => ({
|
||||
AudiobookshelfLibraryService: MockAbsService,
|
||||
}));
|
||||
|
||||
describe('Library service factory', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearLibraryServiceCache();
|
||||
});
|
||||
|
||||
it('returns Plex service when backend mode is plex', async () => {
|
||||
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
||||
|
||||
const service = await getLibraryService();
|
||||
|
||||
expect(service).toBeInstanceOf(MockPlexService);
|
||||
});
|
||||
|
||||
it('returns cached service when mode is unchanged', async () => {
|
||||
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
||||
|
||||
const first = await getLibraryService();
|
||||
const second = await getLibraryService();
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it('switches to Audiobookshelf service when mode changes', async () => {
|
||||
configServiceMock.getBackendMode
|
||||
.mockResolvedValueOnce('plex')
|
||||
.mockResolvedValueOnce('audiobookshelf');
|
||||
|
||||
const first = await getLibraryService();
|
||||
const second = await getLibraryService();
|
||||
|
||||
expect(first).toBeInstanceOf(MockPlexService);
|
||||
expect(second).toBeInstanceOf(MockAbsService);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Component: Plex Library Service Tests
|
||||
* Documentation: documentation/features/audiobookshelf-integration.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PlexLibraryService } from '@/lib/services/library/PlexLibraryService';
|
||||
|
||||
const plexServiceMock = vi.hoisted(() => ({
|
||||
testConnection: vi.fn(),
|
||||
getLibraries: vi.fn(),
|
||||
getLibraryContent: vi.fn(),
|
||||
getRecentlyAdded: vi.fn(),
|
||||
getItemMetadata: vi.fn(),
|
||||
searchLibrary: vi.fn(),
|
||||
scanLibrary: vi.fn(),
|
||||
}));
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getPlexConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/plex.service', () => ({
|
||||
getPlexService: () => plexServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
describe('PlexLibraryService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns error when Plex config is incomplete', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: null, authToken: null });
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Plex server configuration is incomplete');
|
||||
});
|
||||
|
||||
it('returns server info on successful test', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' });
|
||||
plexServiceMock.testConnection.mockResolvedValue({
|
||||
success: true,
|
||||
info: {
|
||||
platform: 'Plex',
|
||||
version: '1.0.0',
|
||||
machineIdentifier: 'machine',
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.serverInfo).toEqual({
|
||||
name: 'Plex',
|
||||
version: '1.0.0',
|
||||
platform: 'Plex',
|
||||
identifier: 'machine',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when testConnection throws', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' });
|
||||
plexServiceMock.testConnection.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('boom');
|
||||
});
|
||||
|
||||
it('maps libraries and items', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' });
|
||||
plexServiceMock.getLibraries.mockResolvedValue([
|
||||
{ id: 'lib-1', title: 'Audiobooks', type: 'artist', itemCount: 5 },
|
||||
]);
|
||||
plexServiceMock.getLibraryContent.mockResolvedValue([
|
||||
{
|
||||
ratingKey: 'rk-1',
|
||||
guid: 'com.plexapp.agents.audible://B00ABC1234?lang=en',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
narrator: 'Narrator',
|
||||
summary: 'Summary',
|
||||
thumb: '/thumb',
|
||||
duration: 120000,
|
||||
year: 2020,
|
||||
addedAt: 1700000000,
|
||||
updatedAt: 1700000100,
|
||||
},
|
||||
]);
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
const libs = await service.getLibraries();
|
||||
const items = await service.getLibraryItems('lib-1');
|
||||
|
||||
expect(libs).toEqual([{ id: 'lib-1', name: 'Audiobooks', type: 'artist', itemCount: 5 }]);
|
||||
expect(items[0]).toEqual({
|
||||
id: 'rk-1',
|
||||
externalId: 'com.plexapp.agents.audible://B00ABC1234?lang=en',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
narrator: 'Narrator',
|
||||
description: 'Summary',
|
||||
coverUrl: '/thumb',
|
||||
duration: 120,
|
||||
asin: 'B00ABC1234',
|
||||
isbn: undefined,
|
||||
year: 2020,
|
||||
addedAt: new Date(1700000000 * 1000),
|
||||
updatedAt: new Date(1700000100 * 1000),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for getItem when metadata is unavailable', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' });
|
||||
plexServiceMock.getItemMetadata.mockResolvedValue({ userRating: 4 });
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
const item = await service.getItem('rk-1');
|
||||
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
|
||||
it('triggers Plex scans and searches', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' });
|
||||
plexServiceMock.searchLibrary.mockResolvedValue([
|
||||
{
|
||||
ratingKey: 'rk-2',
|
||||
guid: 'plex://album/abc',
|
||||
title: 'Search Title',
|
||||
author: 'Search Author',
|
||||
addedAt: 1700000000,
|
||||
updatedAt: 1700000000,
|
||||
},
|
||||
]);
|
||||
plexServiceMock.scanLibrary.mockResolvedValue(undefined);
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
const results = await service.searchItems('lib-1', 'Search');
|
||||
await service.triggerLibraryScan('lib-1');
|
||||
|
||||
expect(results[0].title).toBe('Search Title');
|
||||
expect(results[0].asin).toBeUndefined();
|
||||
expect(plexServiceMock.scanLibrary).toHaveBeenCalledWith('http://plex', 'token', 'lib-1');
|
||||
});
|
||||
|
||||
it('maps recently added items with missing duration', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' });
|
||||
plexServiceMock.getRecentlyAdded.mockResolvedValue([
|
||||
{
|
||||
ratingKey: 'rk-3',
|
||||
guid: 'plex://album/xyz',
|
||||
title: 'Recent Title',
|
||||
author: 'Author',
|
||||
addedAt: 1700000000,
|
||||
updatedAt: 1700000100,
|
||||
},
|
||||
]);
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
const items = await service.getRecentlyAdded('lib-1', 5);
|
||||
|
||||
expect(items[0]).toEqual(expect.objectContaining({
|
||||
id: 'rk-3',
|
||||
title: 'Recent Title',
|
||||
asin: undefined,
|
||||
duration: undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
it('throws when server info cannot be retrieved', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' });
|
||||
plexServiceMock.testConnection.mockResolvedValue({ success: false, message: 'down' });
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
|
||||
await expect(service.getServerInfo()).rejects.toThrow('Failed to get server information');
|
||||
});
|
||||
|
||||
it('throws when libraries are fetched without config', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: null, authToken: null });
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
|
||||
await expect(service.getLibraries()).rejects.toThrow('Plex server configuration is incomplete');
|
||||
});
|
||||
|
||||
it('returns null when getItem metadata lookup fails', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: 'http://plex', authToken: 'token' });
|
||||
plexServiceMock.getItemMetadata.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
const item = await service.getItem('rk-2');
|
||||
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
|
||||
it('throws when triggerLibraryScan is called without config', async () => {
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: null, authToken: null });
|
||||
|
||||
const service = new PlexLibraryService();
|
||||
|
||||
await expect(service.triggerLibraryScan('lib-1')).rejects.toThrow('Plex server configuration is incomplete');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Component: Request Delete Service Tests
|
||||
* Documentation: documentation/admin-features/request-deletion.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const fsMock = {
|
||||
access: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
};
|
||||
const configServiceMock = {
|
||||
get: vi.fn(),
|
||||
getBackendMode: vi.fn(),
|
||||
};
|
||||
const qbtMock = {
|
||||
getTorrent: vi.fn(),
|
||||
deleteTorrent: vi.fn(),
|
||||
};
|
||||
const sabMock = {
|
||||
deleteNZB: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => fsMock);
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
getQBittorrentService: async () => qbtMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
getSABnzbdService: async () => sabMock,
|
||||
}));
|
||||
|
||||
describe('deleteRequest', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns not found when request is missing', async () => {
|
||||
prismaMock.request.findFirst.mockResolvedValue(null);
|
||||
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
||||
|
||||
const result = await deleteRequest('req-1', 'admin-1');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('deletes completed qBittorrent downloads when seeding requirement met', async () => {
|
||||
prismaMock.request.findFirst.mockResolvedValue({
|
||||
id: 'req-1',
|
||||
audiobook: {
|
||||
id: 'ab-1',
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
audibleAsin: 'ASIN1',
|
||||
plexGuid: 'plex-1',
|
||||
absItemId: null,
|
||||
},
|
||||
downloadHistory: [
|
||||
{
|
||||
torrentHash: 'hash-1',
|
||||
indexerName: 'IndexerA',
|
||||
downloadStatus: 'completed',
|
||||
},
|
||||
],
|
||||
});
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 1 }]);
|
||||
}
|
||||
if (key === 'media_dir') {
|
||||
return '/media';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
name: 'Book',
|
||||
seeding_time: 120,
|
||||
});
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValue({
|
||||
releaseDate: '2021-01-01T00:00:00.000Z',
|
||||
});
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{ id: 'lib-1', title: 'Book', author: 'Author' },
|
||||
]);
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.rm.mockResolvedValue(undefined);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
|
||||
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
||||
const result = await deleteRequest('req-1', 'admin-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.torrentsRemoved).toBe(1);
|
||||
expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true);
|
||||
expect(prismaMock.plexLibrary.delete).toHaveBeenCalledWith({ where: { id: 'lib-1' } });
|
||||
|
||||
const expectedPath = path.join('/media', 'Author', 'Book (2021) ASIN1');
|
||||
expect(fsMock.rm).toHaveBeenCalledWith(expectedPath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('removes SABnzbd downloads and continues cleanup', async () => {
|
||||
prismaMock.request.findFirst.mockResolvedValue({
|
||||
id: 'req-2',
|
||||
audiobook: {
|
||||
id: 'ab-2',
|
||||
title: 'Book Two',
|
||||
author: 'Author',
|
||||
audibleAsin: null,
|
||||
plexGuid: 'plex-2',
|
||||
absItemId: null,
|
||||
},
|
||||
downloadHistory: [
|
||||
{
|
||||
nzbId: 'nzb-1',
|
||||
indexerName: 'IndexerB',
|
||||
downloadStatus: 'completed',
|
||||
},
|
||||
],
|
||||
});
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ name: 'IndexerB', seedingTimeMinutes: 0 }]);
|
||||
}
|
||||
if (key === 'media_dir') {
|
||||
return '/media';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
||||
sabMock.deleteNZB.mockResolvedValue(undefined);
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.rm.mockResolvedValue(undefined);
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
|
||||
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
||||
const result = await deleteRequest('req-2', 'admin-1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.torrentsRemoved).toBe(1);
|
||||
expect(sabMock.deleteNZB).toHaveBeenCalledWith('nzb-1', true);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ deletedBy: 'admin-1' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps torrents seeding when requirement is not met and deletes fallback path', async () => {
|
||||
prismaMock.request.findFirst.mockResolvedValue({
|
||||
id: 'req-3',
|
||||
audiobook: {
|
||||
id: 'ab-3',
|
||||
title: 'Book Three',
|
||||
author: 'Author Name',
|
||||
audibleAsin: 'ASIN3',
|
||||
plexGuid: 'plex-3',
|
||||
absItemId: null,
|
||||
},
|
||||
downloadHistory: [
|
||||
{
|
||||
torrentHash: 'hash-3',
|
||||
indexerName: 'IndexerC',
|
||||
downloadStatus: 'completed',
|
||||
},
|
||||
],
|
||||
});
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ name: 'IndexerC', seedingTimeMinutes: 10 }]);
|
||||
}
|
||||
if (key === 'media_dir') {
|
||||
return '/media';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
name: 'Book Three',
|
||||
seeding_time: 60,
|
||||
});
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValue({
|
||||
releaseDate: '2020-01-01T00:00:00.000Z',
|
||||
});
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{ id: 'lib-2', title: 'Book Three', author: 'Other' },
|
||||
]);
|
||||
fsMock.access
|
||||
.mockRejectedValueOnce(new Error('missing'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
fsMock.rm.mockResolvedValue(undefined);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
|
||||
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
||||
const result = await deleteRequest('req-3', 'admin-2');
|
||||
|
||||
expect(result.torrentsKeptSeeding).toBe(1);
|
||||
expect(qbtMock.deleteTorrent).not.toHaveBeenCalled();
|
||||
|
||||
const fallbackPath = path.join('/media', 'Author Name', 'Book Three');
|
||||
expect(fsMock.rm).toHaveBeenCalledWith(fallbackPath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps torrents for unlimited seeding when no config is present', async () => {
|
||||
prismaMock.request.findFirst.mockResolvedValue({
|
||||
id: 'req-4',
|
||||
audiobook: {
|
||||
id: 'ab-4',
|
||||
title: 'Book Four',
|
||||
author: 'Author',
|
||||
audibleAsin: null,
|
||||
plexGuid: 'plex-4',
|
||||
absItemId: null,
|
||||
},
|
||||
downloadHistory: [
|
||||
{
|
||||
torrentHash: 'hash-4',
|
||||
indexerName: 'IndexerD',
|
||||
downloadStatus: 'completed',
|
||||
},
|
||||
],
|
||||
});
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return null;
|
||||
}
|
||||
if (key === 'media_dir') {
|
||||
return '/media';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
name: 'Book Four',
|
||||
seeding_time: 0,
|
||||
});
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
|
||||
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
||||
const result = await deleteRequest('req-4', 'admin-3');
|
||||
|
||||
expect(result.torrentsKeptUnlimited).toBe(1);
|
||||
expect(qbtMock.deleteTorrent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears audiobookshelf linkage when SABnzbd delete fails', async () => {
|
||||
prismaMock.request.findFirst.mockResolvedValue({
|
||||
id: 'req-5',
|
||||
audiobook: {
|
||||
id: 'ab-5',
|
||||
title: 'Book Five',
|
||||
author: 'Author',
|
||||
audibleAsin: null,
|
||||
plexGuid: null,
|
||||
absItemId: 'abs-5',
|
||||
},
|
||||
downloadHistory: [
|
||||
{
|
||||
nzbId: 'nzb-5',
|
||||
indexerName: 'IndexerE',
|
||||
downloadStatus: 'completed',
|
||||
},
|
||||
],
|
||||
});
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ name: 'IndexerE', seedingTimeMinutes: 0 }]);
|
||||
}
|
||||
if (key === 'media_dir') {
|
||||
return '/media';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
sabMock.deleteNZB.mockRejectedValue(new Error('missing'));
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
|
||||
const { deleteRequest } = await import('@/lib/services/request-delete.service');
|
||||
const result = await deleteRequest('req-5', 'admin-5');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prismaMock.audiobook.update).toHaveBeenCalledWith({
|
||||
where: { id: 'ab-5' },
|
||||
data: expect.objectContaining({ absItemId: null }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* Component: Scheduler Service Tests
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addRepeatableJob: vi.fn(),
|
||||
removeRepeatableJob: vi.fn(),
|
||||
addPlexScanJob: vi.fn(),
|
||||
addPlexRecentlyAddedJob: vi.fn(),
|
||||
addAudibleRefreshJob: vi.fn(),
|
||||
addRetryMissingTorrentsJob: vi.fn(),
|
||||
addRetryFailedImportsJob: vi.fn(),
|
||||
addCleanupSeededTorrentsJob: vi.fn(),
|
||||
addMonitorRssFeedsJob: vi.fn(),
|
||||
}));
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
getMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
describe('SchedulerService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
prismaMock.scheduledJob.findFirst.mockReset();
|
||||
prismaMock.scheduledJob.create.mockReset();
|
||||
prismaMock.scheduledJob.findMany.mockReset();
|
||||
prismaMock.scheduledJob.findUnique.mockReset();
|
||||
prismaMock.scheduledJob.update.mockReset();
|
||||
prismaMock.scheduledJob.delete.mockReset();
|
||||
configServiceMock.getBackendMode.mockReset();
|
||||
configServiceMock.getMany.mockReset();
|
||||
});
|
||||
|
||||
it('initializes defaults and schedules enabled jobs', async () => {
|
||||
prismaMock.scheduledJob.findFirst.mockResolvedValue(null);
|
||||
prismaMock.scheduledJob.create.mockResolvedValue({});
|
||||
prismaMock.scheduledJob.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'job-1',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 0 * * *',
|
||||
enabled: true,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
await service.start();
|
||||
|
||||
expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(7);
|
||||
expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
|
||||
'audible_refresh',
|
||||
{ scheduledJobId: 'job-1' },
|
||||
'0 0 * * *',
|
||||
'scheduled-job-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid cron expressions', async () => {
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
|
||||
await expect(
|
||||
service.createScheduledJob({
|
||||
name: 'Bad job',
|
||||
type: 'audible_refresh',
|
||||
schedule: 'bad',
|
||||
})
|
||||
).rejects.toThrow('Invalid cron expression format');
|
||||
});
|
||||
|
||||
it('creates and schedules enabled jobs', async () => {
|
||||
prismaMock.scheduledJob.create.mockResolvedValue({
|
||||
id: 'job-2',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 0 * * *',
|
||||
enabled: true,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
await service.createScheduledJob({
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 0 * * *',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
|
||||
'audible_refresh',
|
||||
{ scheduledJobId: 'job-2' },
|
||||
'0 0 * * *',
|
||||
'scheduled-job-2'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns scheduled jobs and single jobs', async () => {
|
||||
prismaMock.scheduledJob.findMany.mockResolvedValue([{ id: 'job-2' }]);
|
||||
prismaMock.scheduledJob.findUnique.mockResolvedValue({ id: 'job-2' });
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
const jobs = await service.getScheduledJobs();
|
||||
const job = await service.getScheduledJob('job-2');
|
||||
|
||||
expect(prismaMock.scheduledJob.findMany).toHaveBeenCalledWith({ orderBy: { name: 'asc' } });
|
||||
expect(prismaMock.scheduledJob.findUnique).toHaveBeenCalledWith({ where: { id: 'job-2' } });
|
||||
expect(jobs).toEqual([{ id: 'job-2' }]);
|
||||
expect(job).toEqual({ id: 'job-2' });
|
||||
});
|
||||
|
||||
it('updates jobs and reschedules when enabled', async () => {
|
||||
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
||||
id: 'job-3',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 0 * * *',
|
||||
enabled: true,
|
||||
payload: {},
|
||||
});
|
||||
prismaMock.scheduledJob.update.mockResolvedValue({
|
||||
id: 'job-3',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '*/15 * * * *',
|
||||
enabled: true,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
await service.updateScheduledJob('job-3', { schedule: '*/15 * * * *' });
|
||||
|
||||
expect(jobQueueMock.removeRepeatableJob).toHaveBeenCalledWith(
|
||||
'audible_refresh',
|
||||
'0 0 * * *',
|
||||
'scheduled-job-3'
|
||||
);
|
||||
expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
|
||||
'audible_refresh',
|
||||
{ scheduledJobId: 'job-3' },
|
||||
'*/15 * * * *',
|
||||
'scheduled-job-3'
|
||||
);
|
||||
});
|
||||
|
||||
it('unschedules jobs when disabling updates', async () => {
|
||||
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
||||
id: 'job-3b',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 0 * * *',
|
||||
enabled: true,
|
||||
payload: {},
|
||||
});
|
||||
prismaMock.scheduledJob.update.mockResolvedValue({
|
||||
id: 'job-3b',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 0 * * *',
|
||||
enabled: false,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
await service.updateScheduledJob('job-3b', { enabled: false });
|
||||
|
||||
expect(jobQueueMock.removeRepeatableJob).toHaveBeenCalledWith(
|
||||
'audible_refresh',
|
||||
'0 0 * * *',
|
||||
'scheduled-job-3b'
|
||||
);
|
||||
expect(jobQueueMock.addRepeatableJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers Plex scan jobs with validated config', async () => {
|
||||
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
||||
id: 'job-4',
|
||||
name: 'Library Scan',
|
||||
type: 'plex_library_scan',
|
||||
schedule: '0 */6 * * *',
|
||||
enabled: true,
|
||||
payload: {},
|
||||
});
|
||||
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
plex_url: 'http://plex',
|
||||
plex_token: 'token',
|
||||
plex_audiobook_library_id: 'lib-1',
|
||||
});
|
||||
jobQueueMock.addPlexScanJob.mockResolvedValue('bull-1');
|
||||
prismaMock.scheduledJob.update.mockResolvedValue({});
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
const jobId = await service.triggerJobNow('job-4');
|
||||
|
||||
expect(jobId).toBe('bull-1');
|
||||
expect(jobQueueMock.addPlexScanJob).toHaveBeenCalledWith('lib-1', undefined, undefined);
|
||||
expect(prismaMock.scheduledJob.update).toHaveBeenCalledWith({
|
||||
where: { id: 'job-4' },
|
||||
data: {
|
||||
lastRun: expect.any(Date),
|
||||
lastRunJobId: 'bull-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers Audiobookshelf scans when configured', async () => {
|
||||
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
||||
id: 'job-4b',
|
||||
name: 'Library Scan',
|
||||
type: 'plex_library_scan',
|
||||
schedule: '0 */6 * * *',
|
||||
enabled: true,
|
||||
payload: { libraryId: 'abs-lib' },
|
||||
});
|
||||
configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
'audiobookshelf.server_url': 'http://abs',
|
||||
'audiobookshelf.api_token': 'token',
|
||||
'audiobookshelf.library_id': 'abs-lib-2',
|
||||
});
|
||||
jobQueueMock.addPlexScanJob.mockResolvedValue('bull-abs');
|
||||
prismaMock.scheduledJob.update.mockResolvedValue({});
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
const jobId = await service.triggerJobNow('job-4b');
|
||||
|
||||
expect(jobId).toBe('bull-abs');
|
||||
expect(jobQueueMock.addPlexScanJob).toHaveBeenCalledWith('abs-lib', undefined, undefined);
|
||||
});
|
||||
|
||||
it('throws on unknown scheduled job types', async () => {
|
||||
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
||||
id: 'job-5',
|
||||
name: 'Mystery',
|
||||
type: 'unknown',
|
||||
schedule: '* * * * *',
|
||||
enabled: true,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
|
||||
await expect(service.triggerJobNow('job-5')).rejects.toThrow('Unknown job type');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['plex_recently_added_check', 'addPlexRecentlyAddedJob'],
|
||||
['audible_refresh', 'addAudibleRefreshJob'],
|
||||
['retry_missing_torrents', 'addRetryMissingTorrentsJob'],
|
||||
['retry_failed_imports', 'addRetryFailedImportsJob'],
|
||||
['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'],
|
||||
['monitor_rss_feeds', 'addMonitorRssFeedsJob'],
|
||||
])('triggers %s jobs with job queue', async (type, queueMethod) => {
|
||||
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
||||
id: 'job-type',
|
||||
name: 'Job',
|
||||
type,
|
||||
schedule: '* * * * *',
|
||||
enabled: true,
|
||||
payload: {},
|
||||
});
|
||||
(jobQueueMock as any)[queueMethod].mockResolvedValue('bull-type');
|
||||
prismaMock.scheduledJob.update.mockResolvedValue({});
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
const jobId = await service.triggerJobNow('job-type');
|
||||
|
||||
expect(jobId).toBe('bull-type');
|
||||
expect((jobQueueMock as any)[queueMethod]).toHaveBeenCalledWith('job-type');
|
||||
});
|
||||
|
||||
it('parses cron intervals for common patterns', async () => {
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
|
||||
expect((service as any).getIntervalFromCron('*/15 * * * *')).toBe(15 * 60 * 1000);
|
||||
expect((service as any).getIntervalFromCron('0 */6 * * *')).toBe(6 * 60 * 60 * 1000);
|
||||
expect((service as any).getIntervalFromCron('0 4 * * *')).toBe(24 * 60 * 60 * 1000);
|
||||
expect((service as any).getIntervalFromCron('0 4 * * 1')).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
expect((service as any).getIntervalFromCron('invalid cron')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not schedule disabled jobs on creation', async () => {
|
||||
prismaMock.scheduledJob.create.mockResolvedValue({
|
||||
id: 'job-6',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 0 * * *',
|
||||
enabled: false,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
await service.createScheduledJob({
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 0 * * *',
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
expect(jobQueueMock.addRepeatableJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reschedule when updated job stays disabled', async () => {
|
||||
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
||||
id: 'job-7',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 0 * * *',
|
||||
enabled: false,
|
||||
payload: {},
|
||||
});
|
||||
prismaMock.scheduledJob.update.mockResolvedValue({
|
||||
id: 'job-7',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 1 * * *',
|
||||
enabled: false,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
await service.updateScheduledJob('job-7', { schedule: '0 1 * * *', enabled: false });
|
||||
|
||||
expect(jobQueueMock.removeRepeatableJob).not.toHaveBeenCalled();
|
||||
expect(jobQueueMock.addRepeatableJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unschedules jobs when deleted', async () => {
|
||||
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
||||
id: 'job-8',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 0 * * *',
|
||||
enabled: true,
|
||||
payload: {},
|
||||
});
|
||||
prismaMock.scheduledJob.delete.mockResolvedValue({});
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
await service.deleteScheduledJob('job-8');
|
||||
|
||||
expect(jobQueueMock.removeRepeatableJob).toHaveBeenCalledWith(
|
||||
'audible_refresh',
|
||||
'0 0 * * *',
|
||||
'scheduled-job-8'
|
||||
);
|
||||
expect(prismaMock.scheduledJob.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes disabled jobs without unscheduling', async () => {
|
||||
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
||||
id: 'job-8b',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '0 0 * * *',
|
||||
enabled: false,
|
||||
payload: {},
|
||||
});
|
||||
prismaMock.scheduledJob.delete.mockResolvedValue({});
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
await service.deleteScheduledJob('job-8b');
|
||||
|
||||
expect(jobQueueMock.removeRepeatableJob).not.toHaveBeenCalled();
|
||||
expect(prismaMock.scheduledJob.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggers overdue jobs based on lastRun and schedule', async () => {
|
||||
const overdueJob = {
|
||||
id: 'job-9',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '*/5 * * * *',
|
||||
enabled: true,
|
||||
payload: {},
|
||||
lastRun: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
prismaMock.scheduledJob.findMany.mockResolvedValueOnce([overdueJob]);
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
const triggerSpy = vi.spyOn(service, 'triggerJobNow').mockResolvedValue('bull-9');
|
||||
|
||||
await (service as any).triggerOverdueJobs();
|
||||
|
||||
expect(triggerSpy).toHaveBeenCalledWith('job-9');
|
||||
});
|
||||
|
||||
it('logs and continues when overdue jobs fail to trigger', async () => {
|
||||
const overdueJob = {
|
||||
id: 'job-9b',
|
||||
name: 'Audible Data Refresh',
|
||||
type: 'audible_refresh',
|
||||
schedule: '*/5 * * * *',
|
||||
enabled: true,
|
||||
payload: {},
|
||||
lastRun: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||||
};
|
||||
|
||||
prismaMock.scheduledJob.findMany.mockResolvedValueOnce([overdueJob]);
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
const triggerSpy = vi.spyOn(service, 'triggerJobNow').mockRejectedValue(new Error('fail'));
|
||||
|
||||
await expect((service as any).triggerOverdueJobs()).resolves.toBeUndefined();
|
||||
expect(triggerSpy).toHaveBeenCalledWith('job-9b');
|
||||
});
|
||||
it('identifies overdue jobs when lastRun is missing', async () => {
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
|
||||
const overdue = (service as any).isJobOverdue({
|
||||
name: 'No last run',
|
||||
schedule: '0 * * * *',
|
||||
lastRun: null,
|
||||
});
|
||||
|
||||
expect(overdue).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unparseable cron intervals', async () => {
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
|
||||
const overdue = (service as any).isJobOverdue({
|
||||
name: 'Bad cron',
|
||||
schedule: 'bad',
|
||||
lastRun: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(overdue).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when Audiobookshelf scan configuration is missing', async () => {
|
||||
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
||||
id: 'job-10',
|
||||
name: 'Library Scan',
|
||||
type: 'plex_library_scan',
|
||||
schedule: '0 */6 * * *',
|
||||
enabled: true,
|
||||
payload: {},
|
||||
});
|
||||
configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
'audiobookshelf.server_url': null,
|
||||
'audiobookshelf.api_token': null,
|
||||
'audiobookshelf.library_id': null,
|
||||
});
|
||||
|
||||
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
||||
const service = new SchedulerService();
|
||||
|
||||
await expect(service.triggerJobNow('job-10')).rejects.toThrow('Audiobookshelf is not configured');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Component: Thumbnail Cache Service Tests
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ThumbnailCacheService } from '@/lib/services/thumbnail-cache.service';
|
||||
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
mkdir: vi.fn(),
|
||||
access: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
}));
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
describe('ThumbnailCacheService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fsMock.mkdir.mockReset();
|
||||
fsMock.access.mockReset();
|
||||
fsMock.writeFile.mockReset();
|
||||
fsMock.readdir.mockReset();
|
||||
fsMock.unlink.mockReset();
|
||||
axiosMock.get.mockReset();
|
||||
});
|
||||
|
||||
it('returns null when missing ASIN or URL', async () => {
|
||||
const service = new ThumbnailCacheService();
|
||||
|
||||
expect(await service.cacheThumbnail('', 'http://example.com/x.jpg')).toBeNull();
|
||||
expect(await service.cacheThumbnail('ASIN', '')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns cached path when file already exists', async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
|
||||
const service = new ThumbnailCacheService();
|
||||
const result = await service.cacheThumbnail('ASIN1', 'https://img.example.com/cover.jpg');
|
||||
|
||||
expect(result).toBe(path.join('/app/cache/thumbnails', 'ASIN1.jpg'));
|
||||
expect(axiosMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips non-image content types', async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
axiosMock.get.mockResolvedValue({
|
||||
headers: { 'content-type': 'text/html' },
|
||||
data: Buffer.from('nope'),
|
||||
});
|
||||
|
||||
const service = new ThumbnailCacheService();
|
||||
const result = await service.cacheThumbnail('ASIN2', 'https://img.example.com/cover.png');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(fsMock.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads and caches image content', async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
axiosMock.get.mockResolvedValue({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
data: Buffer.from([1, 2, 3]),
|
||||
});
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
|
||||
const service = new ThumbnailCacheService();
|
||||
const result = await service.cacheThumbnail('ASIN3', 'https://img.example.com/cover.jpeg');
|
||||
|
||||
expect(result).toBe(path.join('/app/cache/thumbnails', 'ASIN3.jpeg'));
|
||||
expect(fsMock.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes thumbnails for a specific ASIN', async () => {
|
||||
fsMock.readdir.mockResolvedValue(['ASIN4.jpg', 'ASIN4.png', 'OTHER.jpg']);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const service = new ThumbnailCacheService();
|
||||
await service.deleteThumbnail('ASIN4');
|
||||
|
||||
expect(fsMock.unlink).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('cleans up unused thumbnails', async () => {
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.readdir.mockResolvedValue(['KEEP.jpg', 'DROP.jpg']);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const service = new ThumbnailCacheService();
|
||||
const deleted = await service.cleanupUnusedThumbnails(new Set(['KEEP']));
|
||||
|
||||
expect(deleted).toBe(1);
|
||||
expect(fsMock.unlink).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('maps cached paths for serving', () => {
|
||||
const service = new ThumbnailCacheService();
|
||||
|
||||
expect(service.getCachedPath(null)).toBeNull();
|
||||
expect(service.getCachedPath('/app/cache/thumbnails/ASIN.jpg')).toBe('/cache/thumbnails/ASIN.jpg');
|
||||
});
|
||||
|
||||
it('exposes the cache directory', () => {
|
||||
const service = new ThumbnailCacheService();
|
||||
|
||||
expect(service.getCacheDirectory()).toBe('/app/cache/thumbnails');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user