mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-18 20:20: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,62 @@
|
||||
/**
|
||||
* Component: Admin Backend Mode API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
setMany: vi.fn(),
|
||||
}));
|
||||
const clearLibraryServiceCacheMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
ConfigurationService: class {
|
||||
getBackendMode = configServiceMock.getBackendMode;
|
||||
setMany = configServiceMock.setMany;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/library', () => ({
|
||||
clearLibraryServiceCache: clearLibraryServiceCacheMock,
|
||||
}));
|
||||
|
||||
describe('Admin backend mode route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns backend mode', async () => {
|
||||
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
||||
|
||||
const { GET } = await import('@/app/api/admin/backend-mode/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.backendMode).toBe('plex');
|
||||
});
|
||||
|
||||
it('updates backend mode and clears cache', async () => {
|
||||
const { PUT } = await import('@/app/api/admin/backend-mode/route');
|
||||
const response = await PUT({ json: vi.fn().mockResolvedValue({ mode: 'audiobookshelf' }) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(clearLibraryServiceCacheMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Component: Admin BookDate API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
describe('Admin BookDate toggle route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = {
|
||||
user: { id: 'admin-1', role: 'admin' },
|
||||
json: vi.fn(),
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
});
|
||||
|
||||
it('toggles BookDate enabled state', async () => {
|
||||
authRequest.json.mockResolvedValue({ isEnabled: true });
|
||||
prismaMock.bookDateConfig.updateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
const { PATCH } = await import('@/app/api/admin/bookdate/toggle/route');
|
||||
const response = await PATCH({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.isEnabled).toBe(true);
|
||||
expect(prismaMock.bookDateConfig.updateMany).toHaveBeenCalledWith({ data: { isEnabled: true } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Component: Admin Downloads API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const configServiceMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const qbittorrentMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
getQBittorrentService: async () => qbittorrentMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
getSABnzbdService: async () => ({ getNZB: vi.fn() }),
|
||||
}));
|
||||
|
||||
describe('Admin downloads route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns formatted active downloads', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-1',
|
||||
status: 'downloading',
|
||||
progress: 50,
|
||||
updatedAt: new Date(),
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading' }],
|
||||
},
|
||||
]);
|
||||
configServiceMock.get.mockResolvedValueOnce('qbittorrent');
|
||||
qbittorrentMock.getTorrent.mockResolvedValueOnce({ dlspeed: 123, eta: 60 });
|
||||
|
||||
const { GET } = await import('@/app/api/admin/downloads/active/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.downloads[0].speed).toBe(123);
|
||||
expect(payload.downloads[0].torrentName).toBe('Torrent');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Component: Admin Job Status API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const verifyAccessTokenMock = vi.hoisted(() => vi.fn());
|
||||
const jobQueueMock = vi.hoisted(() => ({ getJob: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/utils/jwt', () => ({
|
||||
verifyAccessToken: verifyAccessTokenMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
const makeRequest = (token?: string) => ({
|
||||
headers: {
|
||||
get: (key: string) => (key.toLowerCase() === 'authorization' ? token : null),
|
||||
},
|
||||
});
|
||||
|
||||
describe('Admin job status route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('rejects missing authorization', async () => {
|
||||
const { GET } = await import('@/app/api/admin/job-status/[id]/route');
|
||||
const response = await GET(makeRequest() as any, { params: Promise.resolve({ id: '1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns job status for admin token', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'admin' });
|
||||
jobQueueMock.getJob.mockResolvedValue({
|
||||
id: '1',
|
||||
type: 'search',
|
||||
status: 'completed',
|
||||
createdAt: new Date(),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
result: null,
|
||||
errorMessage: null,
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/admin/job-status/[id]/route');
|
||||
const response = await GET(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: '1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.job.status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Component: Admin Jobs API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const verifyAccessTokenMock = vi.hoisted(() => vi.fn());
|
||||
const schedulerMock = vi.hoisted(() => ({
|
||||
getScheduledJobs: vi.fn(),
|
||||
createScheduledJob: vi.fn(),
|
||||
updateScheduledJob: vi.fn(),
|
||||
deleteScheduledJob: vi.fn(),
|
||||
triggerJobNow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/jwt', () => ({
|
||||
verifyAccessToken: verifyAccessTokenMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/scheduler.service', () => ({
|
||||
getSchedulerService: () => schedulerMock,
|
||||
}));
|
||||
|
||||
const makeRequest = (token?: string, body?: any) => ({
|
||||
headers: {
|
||||
get: (key: string) => (key.toLowerCase() === 'authorization' ? token : null),
|
||||
},
|
||||
json: vi.fn().mockResolvedValue(body || {}),
|
||||
});
|
||||
|
||||
describe('Admin jobs routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
verifyAccessTokenMock.mockReturnValue({ role: 'admin' });
|
||||
});
|
||||
|
||||
it('lists scheduled jobs', async () => {
|
||||
schedulerMock.getScheduledJobs.mockResolvedValue([{ id: 'job-1' }]);
|
||||
const { GET } = await import('@/app/api/admin/jobs/route');
|
||||
|
||||
const response = await GET(makeRequest('Bearer token') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.jobs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates a scheduled job', async () => {
|
||||
schedulerMock.createScheduledJob.mockResolvedValue({ id: 'job-2' });
|
||||
const { POST } = await import('@/app/api/admin/jobs/route');
|
||||
|
||||
const response = await POST(makeRequest('Bearer token', { name: 'Job', type: 'type', schedule: '* * * * *' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.job.id).toBe('job-2');
|
||||
});
|
||||
|
||||
it('updates a scheduled job', async () => {
|
||||
schedulerMock.updateScheduledJob.mockResolvedValue({ id: 'job-3' });
|
||||
const { PUT } = await import('@/app/api/admin/jobs/[id]/route');
|
||||
|
||||
const response = await PUT(makeRequest('Bearer token', { name: 'Job' }) as any, { params: Promise.resolve({ id: 'job-3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('deletes a scheduled job', async () => {
|
||||
schedulerMock.deleteScheduledJob.mockResolvedValue(undefined);
|
||||
const { DELETE } = await import('@/app/api/admin/jobs/[id]/route');
|
||||
|
||||
const response = await DELETE(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'job-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('triggers a scheduled job', async () => {
|
||||
schedulerMock.triggerJobNow.mockResolvedValue('job-5');
|
||||
const { POST } = await import('@/app/api/admin/jobs/[id]/trigger/route');
|
||||
|
||||
const response = await POST(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'job-5' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.jobId).toBe('job-5');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Component: Admin Logs API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
describe('Admin logs route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns paginated logs', async () => {
|
||||
prismaMock.job.findMany.mockResolvedValueOnce([{ id: 'job-1' }]);
|
||||
prismaMock.job.count.mockResolvedValueOnce(1);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/logs/route');
|
||||
const response = await GET({ url: 'http://localhost/api/admin/logs?page=1&limit=10' } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.logs).toHaveLength(1);
|
||||
expect(payload.pagination.total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Component: Admin Metrics API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
describe('Admin metrics route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns metrics and system health', async () => {
|
||||
prismaMock.request.count
|
||||
.mockResolvedValueOnce(10)
|
||||
.mockResolvedValueOnce(2)
|
||||
.mockResolvedValueOnce(5)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(0);
|
||||
prismaMock.user.count.mockResolvedValueOnce(3);
|
||||
prismaMock.$queryRaw.mockResolvedValueOnce(1);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/metrics/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.totalRequests).toBe(10);
|
||||
expect(payload.systemHealth.status).toBe('healthy');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Component: Admin Plex API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const scanPlexMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/scan-plex.processor', () => ({
|
||||
processScanPlex: scanPlexMock,
|
||||
}));
|
||||
|
||||
describe('Admin Plex scan route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('triggers a Plex scan', async () => {
|
||||
scanPlexMock.mockResolvedValue({ scanned: 10 });
|
||||
|
||||
const { POST } = await import('@/app/api/admin/plex/scan/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(scanPlexMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Component: Admin Requests API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const deleteRequestMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/request-delete.service', () => ({
|
||||
deleteRequest: deleteRequestMock,
|
||||
}));
|
||||
|
||||
describe('Admin requests routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns recent requests', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-1',
|
||||
status: 'pending',
|
||||
createdAt: new Date(),
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
user: { plexUsername: 'user' },
|
||||
downloadHistory: [{ torrentUrl: 'http://torrent' }],
|
||||
},
|
||||
]);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/requests/recent/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.requests).toHaveLength(1);
|
||||
expect(payload.requests[0].torrentUrl).toBe('http://torrent');
|
||||
});
|
||||
|
||||
it('soft deletes a request via delete service', async () => {
|
||||
deleteRequestMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
message: 'Deleted',
|
||||
filesDeleted: 1,
|
||||
torrentsRemoved: 0,
|
||||
torrentsKeptSeeding: 0,
|
||||
torrentsKeptUnlimited: 0,
|
||||
});
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/requests/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(deleteRequestMock).toHaveBeenCalledWith('req-1', 'admin-1');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Component: Admin Settings Core API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
setMany: vi.fn(),
|
||||
clearCache: vi.fn(),
|
||||
}));
|
||||
const audibleServiceMock = vi.hoisted(() => ({
|
||||
forceReinitialize: vi.fn(),
|
||||
}));
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addAudibleRefreshJob: vi.fn(),
|
||||
}));
|
||||
const plexServiceMock = vi.hoisted(() => ({
|
||||
testConnection: vi.fn(),
|
||||
}));
|
||||
const pathMapperMock = vi.hoisted(() => ({
|
||||
validate: vi.fn(),
|
||||
}));
|
||||
const invalidateQbMock = vi.hoisted(() => vi.fn());
|
||||
const invalidateSabMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
getAudibleService: () => audibleServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/plex.service', () => ({
|
||||
getPlexService: () => plexServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/path-mapper', () => ({
|
||||
PathMapper: pathMapperMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
invalidateQBittorrentService: invalidateQbMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
invalidateSABnzbdService: invalidateSabMock,
|
||||
}));
|
||||
|
||||
describe('Admin settings core routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns settings payload', async () => {
|
||||
prismaMock.configuration.findMany.mockResolvedValueOnce([
|
||||
{ key: 'plex_url', value: 'http://plex' },
|
||||
{ key: 'plex_token', value: 'token' },
|
||||
{ key: 'system.backend_mode', value: 'plex' },
|
||||
]);
|
||||
prismaMock.user.count.mockResolvedValueOnce(0);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/settings/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.plex.url).toBe('http://plex');
|
||||
expect(payload.backendMode).toBe('plex');
|
||||
});
|
||||
|
||||
it('updates Plex settings', async () => {
|
||||
plexServiceMock.testConnection.mockResolvedValue({ success: true, info: { machineIdentifier: 'machine' } });
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
url: 'http://plex',
|
||||
token: 'token',
|
||||
libraryId: 'lib',
|
||||
triggerScanAfterImport: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/plex/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.configuration.upsert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates download client settings', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
remotePathMappingEnabled: false,
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/download-client/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(invalidateQbMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates paths settings', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/paths/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(invalidateQbMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates Prowlarr settings', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/prowlarr/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('updates registration settings', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ enabled: true, requireAdminApproval: false }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/registration/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(configServiceMock.setMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates OIDC settings', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ enabled: true, providerName: 'OIDC', clientSecret: 'secret' }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/oidc/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(configServiceMock.setMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates ebook settings', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({ enabled: true, format: 'epub', baseUrl: 'https://annas-archive.li' }),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/ebook/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(configServiceMock.setMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates Audible region and triggers refresh', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ region: 'us' }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/audible/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addAudibleRefreshJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates Audiobookshelf settings', async () => {
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
serverUrl: 'http://abs',
|
||||
apiToken: 'token',
|
||||
libraryId: 'lib',
|
||||
triggerScanAfterImport: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/audiobookshelf/route');
|
||||
const response = await PUT(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(configServiceMock.setMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Component: Admin Settings Libraries API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const plexServiceMock = vi.hoisted(() => ({ getLibraries: vi.fn() }));
|
||||
const configServiceMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/plex.service', () => ({
|
||||
getPlexService: async () => plexServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
describe('Admin settings libraries routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' } };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns Plex libraries', async () => {
|
||||
configServiceMock.get
|
||||
.mockResolvedValueOnce('http://plex')
|
||||
.mockResolvedValueOnce('token');
|
||||
plexServiceMock.getLibraries.mockResolvedValueOnce([
|
||||
{ key: '1', title: 'Audiobooks', type: 'artist' },
|
||||
{ key: '2', title: 'Movies', type: 'movie' },
|
||||
]);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/settings/plex/libraries/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.libraries).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns Audiobookshelf libraries', async () => {
|
||||
configServiceMock.get
|
||||
.mockResolvedValueOnce('http://abs')
|
||||
.mockResolvedValueOnce('token');
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
libraries: [
|
||||
{ id: '1', name: 'Books', mediaType: 'book', stats: { totalItems: 10 } },
|
||||
{ id: '2', name: 'Music', mediaType: 'music' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/settings/audiobookshelf/libraries/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.libraries).toHaveLength(1);
|
||||
expect(payload.libraries[0].id).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Component: Admin Prowlarr Indexers API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const prowlarrMock = vi.hoisted(() => ({
|
||||
getIndexers: vi.fn(),
|
||||
}));
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
setMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
||||
getProwlarrService: async () => prowlarrMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
describe('Admin Prowlarr indexers route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns indexers with saved config', async () => {
|
||||
prowlarrMock.getIndexers.mockResolvedValueOnce([{ id: 1, name: 'Indexer', protocol: 'torrent' }]);
|
||||
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', priority: 5, seedingTimeMinutes: 10 }]));
|
||||
configServiceMock.get.mockResolvedValueOnce('[]');
|
||||
|
||||
const { GET } = await import('@/app/api/admin/settings/prowlarr/indexers/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.indexers[0].enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('saves indexer configuration', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
indexers: [{ id: 1, name: 'Indexer', enabled: true, priority: 10, seedingTimeMinutes: 0 }],
|
||||
flagConfigs: [],
|
||||
});
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/settings/prowlarr/indexers/route');
|
||||
const response = await PUT({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(configServiceMock.setMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Component: Admin Settings Test API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const plexServiceMock = vi.hoisted(() => ({
|
||||
testConnection: vi.fn(),
|
||||
getLibraries: vi.fn(),
|
||||
}));
|
||||
const prowlarrMock = vi.hoisted(() => ({
|
||||
getIndexers: vi.fn(),
|
||||
}));
|
||||
const qbtMock = vi.hoisted(() => ({
|
||||
testConnectionWithCredentials: vi.fn(),
|
||||
}));
|
||||
const sabnzbdMock = vi.hoisted(() => ({
|
||||
testConnection: vi.fn(),
|
||||
}));
|
||||
const testFlareSolverrMock = vi.hoisted(() => vi.fn());
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
constants: { R_OK: 4 },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/plex.service', () => ({
|
||||
getPlexService: () => plexServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
||||
ProwlarrService: class {
|
||||
constructor() {}
|
||||
getIndexers = prowlarrMock.getIndexers;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
QBittorrentService: {
|
||||
testConnectionWithCredentials: qbtMock.testConnectionWithCredentials,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
SABnzbdService: class {
|
||||
constructor() {}
|
||||
testConnection = sabnzbdMock.testConnection;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/ebook-scraper', () => ({
|
||||
testFlareSolverrConnection: testFlareSolverrMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
}));
|
||||
|
||||
describe('Admin settings test routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('tests Plex connection with stored token', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce({ value: 'token' });
|
||||
plexServiceMock.testConnection.mockResolvedValueOnce({ success: true, info: { platform: 'Plex', version: '1.0' } });
|
||||
plexServiceMock.getLibraries.mockResolvedValueOnce([{ id: '1', title: 'Books', type: 'book' }]);
|
||||
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://plex', token: '********' }) };
|
||||
const { POST } = await import('@/app/api/admin/settings/test-plex/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('tests Prowlarr connection', async () => {
|
||||
prowlarrMock.getIndexers.mockResolvedValueOnce([{ id: 1, name: 'Indexer', protocol: 'torrent', enable: true }]);
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('tests download client connection', async () => {
|
||||
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({ type: 'qbittorrent', url: 'http://qbt', username: 'user', password: 'pass' }),
|
||||
};
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.version).toBe('4.0.0');
|
||||
});
|
||||
|
||||
it('validates required fields for download client testing', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://qbt' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Type and URL are required/);
|
||||
});
|
||||
|
||||
it('rejects invalid download client types', async () => {
|
||||
const request = { json: vi.fn().mockResolvedValue({ type: 'invalid', url: 'http://qbt' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid client type/);
|
||||
});
|
||||
|
||||
it('uses stored password when masked password is provided', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce({ value: 'stored-pass' });
|
||||
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.1.0');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
username: 'user',
|
||||
password: '\u2022\u2022\u2022\u2022',
|
||||
}),
|
||||
};
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(qbtMock.testConnectionWithCredentials).toHaveBeenCalledWith(
|
||||
'http://qbt',
|
||||
'user',
|
||||
'stored-pass',
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns error when masked password is missing in storage', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce(null);
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
username: 'user',
|
||||
password: '\u2022\u2022\u2022\u2022',
|
||||
}),
|
||||
};
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/No stored password/);
|
||||
});
|
||||
|
||||
it('returns error when SABnzbd connection fails', async () => {
|
||||
sabnzbdMock.testConnection.mockResolvedValueOnce({ success: false, error: 'bad key' });
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({ type: 'sabnzbd', url: 'http://sab', password: 'key' }),
|
||||
};
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/bad key/);
|
||||
});
|
||||
|
||||
it('requires path mapping values when enabled', async () => {
|
||||
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0');
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
remotePathMappingEnabled: true,
|
||||
}),
|
||||
};
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Remote path and local path are required/);
|
||||
});
|
||||
|
||||
it('rejects inaccessible local path when mapping is enabled', async () => {
|
||||
qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0');
|
||||
fsMock.access.mockRejectedValueOnce(new Error('missing'));
|
||||
const request = {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
remotePathMappingEnabled: true,
|
||||
remotePath: '/remote',
|
||||
localPath: '/local',
|
||||
}),
|
||||
};
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/test-download-client/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/not accessible/);
|
||||
});
|
||||
|
||||
it('tests FlareSolverr connection', async () => {
|
||||
testFlareSolverrMock.mockResolvedValueOnce({ success: true });
|
||||
const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare' }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route');
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Component: Admin Users API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
describe('Admin users routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { sub: 'admin-1', role: 'admin' }, json: vi.fn() };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
|
||||
});
|
||||
|
||||
it('returns users list', async () => {
|
||||
prismaMock.user.findMany.mockResolvedValueOnce([{ id: 'u1' }]);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/users/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.users).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns pending users list', async () => {
|
||||
prismaMock.user.findMany.mockResolvedValueOnce([{ id: 'u2' }]);
|
||||
|
||||
const { GET } = await import('@/app/api/admin/users/pending/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.users).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('updates a user role', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'local',
|
||||
plexUsername: 'user',
|
||||
deletedAt: null,
|
||||
});
|
||||
prismaMock.user.update.mockResolvedValueOnce({ id: 'u3', plexUsername: 'user', role: 'admin' });
|
||||
const request = { json: vi.fn().mockResolvedValue({ role: 'admin' }) };
|
||||
|
||||
const { PUT } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await PUT(request as any, { params: Promise.resolve({ id: 'u3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.user.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('soft deletes a local user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'u4',
|
||||
plexUsername: 'user',
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'local',
|
||||
deletedAt: null,
|
||||
_count: { requests: 1 },
|
||||
});
|
||||
prismaMock.user.update.mockResolvedValueOnce({});
|
||||
|
||||
const { DELETE } = await import('@/app/api/admin/users/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'u4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('approves a pending user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
id: 'u5',
|
||||
plexUsername: 'user',
|
||||
registrationStatus: 'pending_approval',
|
||||
});
|
||||
prismaMock.user.update.mockResolvedValueOnce({});
|
||||
const request = { json: vi.fn().mockResolvedValue({ approve: true }) };
|
||||
|
||||
const { POST } = await import('@/app/api/admin/users/[id]/approve/route');
|
||||
const response = await POST(request as any, { params: Promise.resolve({ id: 'u5' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Component: Audiobooks Browse API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const audibleServiceMock = vi.hoisted(() => ({
|
||||
search: vi.fn(),
|
||||
getAudiobookDetails: vi.fn(),
|
||||
}));
|
||||
const enrichMock = vi.hoisted(() => vi.fn());
|
||||
const currentUserMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
getAudibleService: () => audibleServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
enrichAudiobooksWithMatches: enrichMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
getCurrentUser: currentUserMock,
|
||||
}));
|
||||
|
||||
describe('Audiobooks browse routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
enrichMock.mockResolvedValue([]);
|
||||
currentUserMock.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('searches Audible and enriches results', async () => {
|
||||
audibleServiceMock.search.mockResolvedValue({
|
||||
query: 'query',
|
||||
results: [{ asin: 'ASIN', title: 'Title', author: 'Author' }],
|
||||
totalResults: 1,
|
||||
page: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
currentUserMock.mockReturnValue({ sub: 'user-1' });
|
||||
enrichMock.mockResolvedValue([{ asin: 'ASIN', available: false }]);
|
||||
|
||||
const { GET } = await import('@/app/api/audiobooks/search/route');
|
||||
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/search?q=query') } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(enrichMock).toHaveBeenCalledWith([{ asin: 'ASIN', title: 'Title', author: 'Author' }], 'user-1');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid popular pagination', async () => {
|
||||
const { GET } = await import('@/app/api/audiobooks/popular/route');
|
||||
|
||||
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/popular?page=0') } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns popular audiobooks with cached cover URLs', async () => {
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
asin: 'ASIN',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
description: null,
|
||||
coverArtUrl: 'http://image',
|
||||
cachedCoverPath: '/tmp/cache/asin.jpg',
|
||||
durationMinutes: 90,
|
||||
releaseDate: new Date('2024-01-01'),
|
||||
rating: 4.5,
|
||||
genres: [],
|
||||
lastSyncedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
prismaMock.audibleCache.count.mockResolvedValueOnce(1);
|
||||
enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', coverArtUrl: '/api/cache/thumbnails/asin.jpg' }]);
|
||||
|
||||
const { GET } = await import('@/app/api/audiobooks/popular/route');
|
||||
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/popular?page=1&limit=1') } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.audiobooks[0].coverArtUrl).toBe('/api/cache/thumbnails/asin.jpg');
|
||||
});
|
||||
|
||||
it('returns new release audiobooks', async () => {
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.audibleCache.count.mockResolvedValueOnce(0);
|
||||
|
||||
const { GET } = await import('@/app/api/audiobooks/new-releases/route');
|
||||
const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.count).toBe(0);
|
||||
});
|
||||
|
||||
it('returns audiobook details when ASIN is valid', async () => {
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValue({ asin: 'ASIN123456', title: 'Title' });
|
||||
const { GET } = await import('@/app/api/audiobooks/[asin]/route');
|
||||
|
||||
const response = await GET({} as any, { params: Promise.resolve({ asin: 'ASIN123456' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.audiobook.asin).toBe('ASIN123456');
|
||||
});
|
||||
|
||||
it('returns cached covers for login', async () => {
|
||||
prismaMock.audibleCache.findMany.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN', title: 'Title', author: 'Author', cachedCoverPath: '/tmp/asin.jpg', coverArtUrl: null },
|
||||
]);
|
||||
const { GET } = await import('@/app/api/audiobooks/covers/route');
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.covers[0].coverUrl).toBe('/api/cache/thumbnails/asin.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Component: Request With Torrent API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addDownloadJob: vi.fn(),
|
||||
}));
|
||||
const findPlexMatchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
findPlexMatch: findPlexMatchMock,
|
||||
}));
|
||||
|
||||
describe('Request with torrent route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = {
|
||||
user: { id: 'user-1', role: 'user' },
|
||||
json: vi.fn(),
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
});
|
||||
|
||||
it('returns 409 when audiobook is already being processed', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' },
|
||||
torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' },
|
||||
});
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'req-1',
|
||||
status: 'downloaded',
|
||||
userId: 'user-2',
|
||||
user: { plexUsername: 'other' },
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(payload.error).toBe('BeingProcessed');
|
||||
});
|
||||
|
||||
it('creates request and queues download job', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' },
|
||||
torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' },
|
||||
});
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
findPlexMatchMock.mockResolvedValueOnce(null);
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' });
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'req-2',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
|
||||
user: { id: 'user-1', plexUsername: 'user' },
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith('req-2', {
|
||||
id: 'ab-1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
}, expect.objectContaining({ guid: 'guid' }));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Component: Audiobooks Search Torrents API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const prowlarrMock = vi.hoisted(() => ({
|
||||
search: vi.fn(),
|
||||
}));
|
||||
const rankTorrentsMock = vi.hoisted(() => vi.fn());
|
||||
const groupIndexersMock = vi.hoisted(() => vi.fn());
|
||||
const groupDescriptionMock = vi.hoisted(() => vi.fn(() => 'Group'));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
||||
getProwlarrService: async () => prowlarrMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/ranking-algorithm', () => ({
|
||||
rankTorrents: rankTorrentsMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/indexer-grouping', () => ({
|
||||
groupIndexersByCategories: groupIndexersMock,
|
||||
getGroupDescription: groupDescriptionMock,
|
||||
}));
|
||||
|
||||
describe('Audiobooks search torrents route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = {
|
||||
user: { id: 'user-1', role: 'user' },
|
||||
json: vi.fn(),
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
});
|
||||
|
||||
it('returns error when no indexers are configured', async () => {
|
||||
authRequest.json.mockResolvedValue({ title: 'Title', author: 'Author' });
|
||||
configServiceMock.get.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/audiobooks/search-torrents/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ConfigError');
|
||||
});
|
||||
|
||||
it('returns ranked results with rank order', async () => {
|
||||
authRequest.json.mockResolvedValue({ title: 'Title', author: 'Author' });
|
||||
configServiceMock.get
|
||||
.mockResolvedValueOnce(JSON.stringify([{ id: 1, name: 'Indexer', priority: 10 }]))
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
groupIndexersMock.mockReturnValue([{ categories: [1], indexerIds: [1] }]);
|
||||
prowlarrMock.search.mockResolvedValue([{ title: 'Result', size: 100, indexer: 'Indexer', indexerId: 1 }]);
|
||||
rankTorrentsMock.mockReturnValue([
|
||||
{
|
||||
title: 'Result',
|
||||
size: 100,
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
score: 50,
|
||||
breakdown: { matchScore: 50, formatScore: 0, sizeScore: 0, seederScore: 0, notes: [] },
|
||||
bonusPoints: 0,
|
||||
bonusModifiers: [],
|
||||
finalScore: 50,
|
||||
},
|
||||
]);
|
||||
|
||||
const { POST } = await import('@/app/api/audiobooks/search-torrents/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.results[0].rank).toBe(1);
|
||||
expect(rankTorrentsMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Component: Admin Login API Route Tests
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const bcryptMock = {
|
||||
compare: vi.fn(),
|
||||
};
|
||||
const encryptionMock = {
|
||||
decrypt: vi.fn(),
|
||||
};
|
||||
const tokenMock = {
|
||||
generateAccessToken: vi.fn(() => 'access-token'),
|
||||
generateRefreshToken: vi.fn(() => 'refresh-token'),
|
||||
};
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('bcrypt', () => ({
|
||||
default: bcryptMock,
|
||||
...bcryptMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/jwt', () => ({
|
||||
generateAccessToken: tokenMock.generateAccessToken,
|
||||
generateRefreshToken: tokenMock.generateRefreshToken,
|
||||
}));
|
||||
|
||||
const makeRequest = (body: Record<string, any>) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
});
|
||||
|
||||
describe('Admin login route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.DISABLE_LOCAL_LOGIN;
|
||||
});
|
||||
|
||||
it('blocks local login when disabled', async () => {
|
||||
process.env.DISABLE_LOCAL_LOGIN = 'true';
|
||||
const { POST } = await import('@/app/api/auth/admin/login/route');
|
||||
|
||||
const response = await POST(makeRequest({ username: 'admin', password: 'pass' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('Local login is disabled');
|
||||
});
|
||||
|
||||
it('rejects missing credentials', async () => {
|
||||
const { POST } = await import('@/app/api/auth/admin/login/route');
|
||||
|
||||
const response = await POST(makeRequest({ username: 'admin' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('rejects unknown user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
const { POST } = await import('@/app/api/auth/admin/login/route');
|
||||
|
||||
const response = await POST(makeRequest({ username: 'admin', password: 'pass' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('AuthenticationError');
|
||||
});
|
||||
|
||||
it('rejects invalid password', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexId: 'local-admin',
|
||||
plexUsername: 'admin',
|
||||
plexEmail: null,
|
||||
role: 'admin',
|
||||
avatarUrl: null,
|
||||
authToken: 'enc-hash',
|
||||
});
|
||||
encryptionMock.decrypt.mockReturnValue('hash');
|
||||
bcryptMock.compare.mockResolvedValue(false);
|
||||
const { POST } = await import('@/app/api/auth/admin/login/route');
|
||||
|
||||
const response = await POST(makeRequest({ username: 'admin', password: 'wrong' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('AuthenticationError');
|
||||
});
|
||||
|
||||
it('rejects when password verification throws', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-2',
|
||||
plexId: 'local-admin',
|
||||
plexUsername: 'admin',
|
||||
plexEmail: null,
|
||||
role: 'admin',
|
||||
avatarUrl: null,
|
||||
authToken: 'enc-hash',
|
||||
});
|
||||
encryptionMock.decrypt.mockImplementation(() => {
|
||||
throw new Error('decrypt failed');
|
||||
});
|
||||
const { POST } = await import('@/app/api/auth/admin/login/route');
|
||||
|
||||
const response = await POST(makeRequest({ username: 'admin', password: 'pass' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('AuthenticationError');
|
||||
});
|
||||
|
||||
it('returns tokens for valid credentials', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-3',
|
||||
plexId: 'local-admin',
|
||||
plexUsername: 'admin',
|
||||
plexEmail: 'admin@example.com',
|
||||
role: 'admin',
|
||||
avatarUrl: null,
|
||||
authToken: 'enc-hash',
|
||||
});
|
||||
prismaMock.user.update.mockResolvedValue({});
|
||||
encryptionMock.decrypt.mockReturnValue('hash');
|
||||
bcryptMock.compare.mockResolvedValue(true);
|
||||
const { POST } = await import('@/app/api/auth/admin/login/route');
|
||||
|
||||
const response = await POST(makeRequest({ username: 'admin', password: 'pass' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
expect(payload.refreshToken).toBe('refresh-token');
|
||||
expect(prismaMock.user.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Component: Change Password API Route Tests
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const bcryptMock = {
|
||||
compare: vi.fn(),
|
||||
hash: vi.fn(),
|
||||
};
|
||||
const encryptionMock = {
|
||||
decrypt: vi.fn(),
|
||||
encrypt: vi.fn(),
|
||||
};
|
||||
const requireAuthMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('bcrypt', () => ({
|
||||
default: bcryptMock,
|
||||
...bcryptMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
const makeRequest = (body: Record<string, any>) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
});
|
||||
|
||||
describe('Change password route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) =>
|
||||
handler({ user: { id: 'user-1' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('validates required fields', async () => {
|
||||
const { POST } = await import('@/app/api/auth/change-password/route');
|
||||
|
||||
const response = await POST(makeRequest({ currentPassword: 'old' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/required/i);
|
||||
});
|
||||
|
||||
it('rejects short passwords', async () => {
|
||||
const { POST } = await import('@/app/api/auth/change-password/route');
|
||||
|
||||
const response = await POST(
|
||||
makeRequest({ currentPassword: 'old', newPassword: 'short', confirmPassword: 'short' }) as any
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/at least 8 characters/i);
|
||||
});
|
||||
|
||||
it('blocks non-local users', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
authProvider: 'plex',
|
||||
authToken: 'enc-hash',
|
||||
plexId: 'plex-1',
|
||||
plexUsername: 'user',
|
||||
});
|
||||
const { POST } = await import('@/app/api/auth/change-password/route');
|
||||
|
||||
const response = await POST(
|
||||
makeRequest({ currentPassword: 'oldpass', newPassword: 'newpass123', confirmPassword: 'newpass123' }) as any
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toMatch(/local users/i);
|
||||
});
|
||||
|
||||
it('rejects incorrect current password', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
authProvider: 'local',
|
||||
authToken: 'enc-hash',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
});
|
||||
encryptionMock.decrypt.mockReturnValue('hash');
|
||||
bcryptMock.compare.mockResolvedValue(false);
|
||||
const { POST } = await import('@/app/api/auth/change-password/route');
|
||||
|
||||
const response = await POST(
|
||||
makeRequest({ currentPassword: 'wrong', newPassword: 'newpass123', confirmPassword: 'newpass123' }) as any
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/incorrect/i);
|
||||
});
|
||||
|
||||
it('returns error when decryption fails', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
authProvider: 'local',
|
||||
authToken: 'enc-hash',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
});
|
||||
encryptionMock.decrypt.mockImplementation(() => {
|
||||
throw new Error('decrypt failed');
|
||||
});
|
||||
const { POST } = await import('@/app/api/auth/change-password/route');
|
||||
|
||||
const response = await POST(
|
||||
makeRequest({ currentPassword: 'oldpass', newPassword: 'newpass123', confirmPassword: 'newpass123' }) as any
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/verify current password/i);
|
||||
});
|
||||
|
||||
it('updates password for local user', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
authProvider: 'local',
|
||||
authToken: 'enc-hash',
|
||||
plexId: 'local-user',
|
||||
plexUsername: 'user',
|
||||
});
|
||||
encryptionMock.decrypt.mockReturnValue('hash');
|
||||
bcryptMock.compare.mockResolvedValue(true);
|
||||
bcryptMock.hash.mockResolvedValue('new-hash');
|
||||
encryptionMock.encrypt.mockReturnValue('enc-new-hash');
|
||||
prismaMock.user.update.mockResolvedValue({});
|
||||
const { POST } = await import('@/app/api/auth/change-password/route');
|
||||
|
||||
const response = await POST(
|
||||
makeRequest({ currentPassword: 'oldpass', newPassword: 'newpass123', confirmPassword: 'newpass123' }) as any
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ authToken: 'enc-new-hash' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Component: Is Local Admin API Route Tests
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const requireAuthMock = vi.fn();
|
||||
const isLocalAdminMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
isLocalAdmin: isLocalAdminMock,
|
||||
}));
|
||||
|
||||
describe('Is local admin route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns false when request has no user', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler({}));
|
||||
const { GET } = await import('@/app/api/auth/is-local-admin/route');
|
||||
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.isLocalAdmin).toBe(false);
|
||||
expect(isLocalAdminMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns local admin status for user', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) =>
|
||||
handler({ user: { id: 'user-1' } })
|
||||
);
|
||||
isLocalAdminMock.mockResolvedValue(true);
|
||||
const { GET } = await import('@/app/api/auth/is-local-admin/route');
|
||||
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.isLocalAdmin).toBe(true);
|
||||
expect(isLocalAdminMock).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Component: Local Auth API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const localAuthProviderMock = {
|
||||
handleCallback: vi.fn(),
|
||||
register: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/lib/services/auth/LocalAuthProvider', () => ({
|
||||
LocalAuthProvider: class {
|
||||
handleCallback = localAuthProviderMock.handleCallback;
|
||||
register = localAuthProviderMock.register;
|
||||
},
|
||||
}));
|
||||
|
||||
const makeRequest = (body: any, headers?: Record<string, string>) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
headers: {
|
||||
get: (key: string) => headers?.[key.toLowerCase()] || null,
|
||||
},
|
||||
});
|
||||
|
||||
describe('Local auth routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.DISABLE_LOCAL_LOGIN;
|
||||
});
|
||||
|
||||
it('rejects login when local auth is disabled', async () => {
|
||||
process.env.DISABLE_LOCAL_LOGIN = 'true';
|
||||
const { POST } = await import('@/app/api/auth/local/login/route');
|
||||
|
||||
const response = await POST(makeRequest({ username: 'user', password: 'pass' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('Local login is disabled');
|
||||
});
|
||||
|
||||
it('rejects login when username or password missing', async () => {
|
||||
const { POST } = await import('@/app/api/auth/local/login/route');
|
||||
|
||||
const response = await POST(makeRequest({ username: 'user' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('Username and password are required');
|
||||
});
|
||||
|
||||
it('logs in successfully with local credentials', async () => {
|
||||
localAuthProviderMock.handleCallback.mockResolvedValue({
|
||||
success: true,
|
||||
user: { id: 'u1' },
|
||||
tokens: { accessToken: 'access', refreshToken: 'refresh' },
|
||||
});
|
||||
const { POST } = await import('@/app/api/auth/local/login/route');
|
||||
|
||||
const response = await POST(makeRequest({ username: 'user', password: 'pass' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.accessToken).toBe('access');
|
||||
});
|
||||
|
||||
it('returns pending approval for local login', async () => {
|
||||
localAuthProviderMock.handleCallback.mockResolvedValue({
|
||||
success: false,
|
||||
requiresApproval: true,
|
||||
});
|
||||
const { POST } = await import('@/app/api/auth/local/login/route');
|
||||
|
||||
const response = await POST(makeRequest({ username: 'user', password: 'pass' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.pendingApproval).toBe(true);
|
||||
});
|
||||
|
||||
it('registers a local user and returns tokens', async () => {
|
||||
localAuthProviderMock.register.mockResolvedValue({
|
||||
success: true,
|
||||
user: { id: 'u2' },
|
||||
tokens: { accessToken: 'access', refreshToken: 'refresh' },
|
||||
});
|
||||
const { POST } = await import('@/app/api/auth/register/route');
|
||||
|
||||
const request = makeRequest({ username: 'user', password: 'pass' }, { 'x-forwarded-for': 'ip-1' });
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.refreshToken).toBe('refresh');
|
||||
});
|
||||
|
||||
it('rate limits repeated registration attempts by IP', async () => {
|
||||
localAuthProviderMock.register.mockResolvedValue({
|
||||
success: true,
|
||||
user: { id: 'u3' },
|
||||
tokens: { accessToken: 'access', refreshToken: 'refresh' },
|
||||
});
|
||||
const { POST } = await import('@/app/api/auth/register/route');
|
||||
|
||||
const request = makeRequest({ username: 'user', password: 'pass' }, { 'x-forwarded-for': 'ip-2' });
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const response = await POST(request as any);
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
|
||||
const blocked = await POST(request as any);
|
||||
const payload = await blocked.json();
|
||||
|
||||
expect(blocked.status).toBe(429);
|
||||
expect(payload.error).toMatch(/Too many registration attempts/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Component: Auth Misc API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const verifyRefreshTokenMock = vi.hoisted(() => vi.fn());
|
||||
const generateAccessTokenMock = vi.hoisted(() => vi.fn());
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/jwt', () => ({
|
||||
verifyRefreshToken: verifyRefreshTokenMock,
|
||||
generateAccessToken: generateAccessTokenMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
ConfigurationService: class {
|
||||
get = configServiceMock.get;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Auth misc routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = {
|
||||
user: { id: 'user-1', role: 'user' },
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
delete process.env.DISABLE_LOCAL_LOGIN;
|
||||
});
|
||||
|
||||
it('logs out successfully', async () => {
|
||||
const { POST } = await import('@/app/api/auth/logout/route');
|
||||
|
||||
const response = await POST();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns current user details with local admin flag', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexId: 'local-admin',
|
||||
plexUsername: 'admin',
|
||||
plexEmail: 'admin@example.com',
|
||||
role: 'admin',
|
||||
isSetupAdmin: true,
|
||||
avatarUrl: null,
|
||||
authProvider: 'local',
|
||||
createdAt: new Date(),
|
||||
lastLoginAt: new Date(),
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/auth/me/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.user.isLocalAdmin).toBe(true);
|
||||
expect(payload.user.username).toBe('admin');
|
||||
});
|
||||
|
||||
it('refreshes access token when refresh token is valid', async () => {
|
||||
verifyRefreshTokenMock.mockReturnValue({ sub: 'user-1' });
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
plexUsername: 'user',
|
||||
role: 'user',
|
||||
});
|
||||
generateAccessTokenMock.mockReturnValue('access-token');
|
||||
|
||||
const { POST } = await import('@/app/api/auth/refresh/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({ refreshToken: 'refresh' }) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
});
|
||||
|
||||
it('returns provider info for audiobookshelf mode', async () => {
|
||||
configServiceMock.get
|
||||
.mockResolvedValueOnce('audiobookshelf')
|
||||
.mockResolvedValueOnce('prowlarr')
|
||||
.mockResolvedValueOnce('http://prowlarr')
|
||||
.mockResolvedValueOnce('true')
|
||||
.mockResolvedValueOnce('true')
|
||||
.mockResolvedValueOnce('MyOIDC');
|
||||
|
||||
prismaMock.user.count.mockResolvedValueOnce(1);
|
||||
|
||||
const { GET } = await import('@/app/api/auth/providers/route');
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.backendMode).toBe('audiobookshelf');
|
||||
expect(payload.providers).toContain('oidc');
|
||||
expect(payload.registrationEnabled).toBe(true);
|
||||
expect(payload.oidcProviderName).toBe('MyOIDC');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Component: OIDC Auth API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const authProviderMock = vi.hoisted(() => ({
|
||||
initiateLogin: vi.fn(),
|
||||
handleCallback: vi.fn(),
|
||||
}));
|
||||
const getBaseUrlMock = vi.hoisted(() => vi.fn(() => 'http://app'));
|
||||
|
||||
vi.mock('@/lib/services/auth', () => ({
|
||||
getAuthProvider: async () => authProviderMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/url', () => ({
|
||||
getBaseUrl: getBaseUrlMock,
|
||||
}));
|
||||
|
||||
const makeRequest = (url: string) => ({
|
||||
nextUrl: new URL(url),
|
||||
});
|
||||
|
||||
describe('OIDC auth routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('redirects to provider on login', async () => {
|
||||
authProviderMock.initiateLogin.mockResolvedValue({ redirectUrl: 'http://oidc/login' });
|
||||
const { GET } = await import('@/app/api/auth/oidc/login/route');
|
||||
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get('location')).toBe('http://oidc/login');
|
||||
});
|
||||
|
||||
it('redirects to login on missing code/state', async () => {
|
||||
const { GET } = await import('@/app/api/auth/oidc/callback/route');
|
||||
|
||||
const response = await GET(makeRequest('http://app/api/auth/oidc/callback') as any);
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get('location')).toContain('/login?error=');
|
||||
});
|
||||
|
||||
it('redirects with pending approval when required', async () => {
|
||||
authProviderMock.handleCallback.mockResolvedValue({ success: false, requiresApproval: true });
|
||||
const { GET } = await import('@/app/api/auth/oidc/callback/route');
|
||||
|
||||
const response = await GET(makeRequest('http://app/api/auth/oidc/callback?code=abc&state=def') as any);
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get('location')).toBe('http://app/login?pending=approval');
|
||||
});
|
||||
|
||||
it('returns HTML response for successful callback', async () => {
|
||||
authProviderMock.handleCallback.mockResolvedValue({
|
||||
success: true,
|
||||
tokens: { accessToken: 'access', refreshToken: 'refresh' },
|
||||
user: { id: 'u1', username: 'user', email: 'a@b.com', avatarUrl: null, isAdmin: false },
|
||||
isFirstLogin: false,
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/auth/oidc/callback/route');
|
||||
const response = await GET(makeRequest('http://app/api/auth/oidc/callback?code=abc&state=def') as any);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/html');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Component: Plex Auth API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const plexServiceMock = vi.hoisted(() => ({
|
||||
requestPin: vi.fn(),
|
||||
getOAuthUrl: vi.fn(),
|
||||
checkPin: vi.fn(),
|
||||
getUserInfo: vi.fn(),
|
||||
verifyServerAccess: vi.fn(),
|
||||
getHomeUsers: vi.fn(),
|
||||
switchHomeUser: vi.fn(),
|
||||
}));
|
||||
const encryptionServiceMock = vi.hoisted(() => ({
|
||||
encrypt: vi.fn((value: string) => `enc-${value}`),
|
||||
}));
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getPlexConfig: vi.fn(),
|
||||
}));
|
||||
const generateAccessTokenMock = vi.hoisted(() => vi.fn(() => 'access-token'));
|
||||
const generateRefreshTokenMock = vi.hoisted(() => vi.fn(() => 'refresh-token'));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/plex.service', () => ({
|
||||
getPlexService: () => plexServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/jwt', () => ({
|
||||
generateAccessToken: generateAccessTokenMock,
|
||||
generateRefreshToken: generateRefreshTokenMock,
|
||||
}));
|
||||
|
||||
const makeRequest = (url: string, headers?: Record<string, string>) => ({
|
||||
nextUrl: new URL(url),
|
||||
headers: {
|
||||
get: (key: string) => headers?.[key.toLowerCase()] || null,
|
||||
},
|
||||
json: vi.fn(),
|
||||
});
|
||||
|
||||
describe('Plex auth routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initiates Plex login and returns auth URL', async () => {
|
||||
plexServiceMock.requestPin.mockResolvedValue({ id: 1, code: 'code-1' });
|
||||
plexServiceMock.getOAuthUrl.mockReturnValue('http://plex/auth');
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/login/route');
|
||||
const response = await POST(makeRequest('http://localhost/api/auth/plex/login', { origin: 'http://app' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.authUrl).toBe('http://plex/auth');
|
||||
expect(plexServiceMock.getOAuthUrl).toHaveBeenCalledWith('code-1', 1, 'http://app/api/auth/plex/callback');
|
||||
});
|
||||
|
||||
it('returns 400 when pinId is missing', async () => {
|
||||
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
||||
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 202 when waiting for authorization', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue(null);
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=1') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(payload.authorized).toBe(false);
|
||||
});
|
||||
|
||||
it('denies access when Plex server is not configured', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({ serverUrl: null, authToken: null });
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(payload.error).toBe('ConfigurationError');
|
||||
});
|
||||
|
||||
it('denies access when machine identifier is missing', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
machineIdentifier: null,
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(payload.error).toBe('ConfigurationError');
|
||||
});
|
||||
|
||||
it('rejects when user lacks server access', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(false);
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('AccessDenied');
|
||||
});
|
||||
|
||||
it('returns errors when Plex user info is incomplete', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: '' });
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=2') as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toBe('OAuthError');
|
||||
expect(payload.details).toContain('Username is missing');
|
||||
});
|
||||
|
||||
it('returns profile selection info when multiple home users exist', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
||||
plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }, { id: 2 }]);
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=3', { accept: 'application/json' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.requiresProfileSelection).toBe(true);
|
||||
expect(payload.homeUsers).toBe(2);
|
||||
});
|
||||
|
||||
it('returns HTML redirect for browser profile selection', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
||||
plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }, { id: 2 }]);
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
||||
const response = await GET(
|
||||
makeRequest('http://localhost/api/auth/plex/callback?pinId=3', {
|
||||
accept: 'text/html',
|
||||
host: 'example.com',
|
||||
'x-forwarded-proto': 'https',
|
||||
}) as any
|
||||
);
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.headers.get('content-type')).toContain('text/html');
|
||||
expect(html).toContain('sessionStorage.setItem');
|
||||
expect(html).toContain('https://example.com/auth/select-profile?pinId=3');
|
||||
});
|
||||
|
||||
it('returns tokens for successful Plex auth', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({ id: 'plex-1', username: 'user' });
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
||||
plexServiceMock.getHomeUsers.mockResolvedValue([]);
|
||||
prismaMock.user.count.mockResolvedValue(0);
|
||||
prismaMock.user.upsert.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
plexUsername: 'user',
|
||||
plexEmail: null,
|
||||
role: 'admin',
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/callback?pinId=4', { accept: 'application/json' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
});
|
||||
|
||||
it('returns HTML redirect with cookies for browser auth', async () => {
|
||||
plexServiceMock.checkPin.mockResolvedValue('token');
|
||||
plexServiceMock.getUserInfo.mockResolvedValue({
|
||||
id: 'plex-1',
|
||||
username: 'user',
|
||||
email: 'user@example.com',
|
||||
thumb: '/t',
|
||||
});
|
||||
configServiceMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
plexServiceMock.verifyServerAccess.mockResolvedValue(true);
|
||||
plexServiceMock.getHomeUsers.mockResolvedValue([]);
|
||||
prismaMock.user.count.mockResolvedValue(1);
|
||||
prismaMock.user.upsert.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
plexUsername: 'user',
|
||||
plexEmail: 'user@example.com',
|
||||
role: 'user',
|
||||
avatarUrl: '/t',
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/callback/route');
|
||||
const response = await GET(
|
||||
makeRequest('http://localhost/api/auth/plex/callback?pinId=4', {
|
||||
accept: 'text/html',
|
||||
host: 'example.com',
|
||||
'x-forwarded-proto': 'https',
|
||||
}) as any
|
||||
);
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.headers.get('content-type')).toContain('text/html');
|
||||
expect(response.cookies.get('accessToken')?.value).toBe('access-token');
|
||||
expect(response.cookies.get('refreshToken')?.value).toBe('refresh-token');
|
||||
expect(html).toContain('#authData=');
|
||||
});
|
||||
|
||||
it('returns Plex home users when token is provided', async () => {
|
||||
plexServiceMock.getHomeUsers.mockResolvedValue([{ id: 1 }]);
|
||||
|
||||
const { GET } = await import('@/app/api/auth/plex/home-users/route');
|
||||
const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-token': 'token' }) as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.users).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('switches Plex profile using provided profile info', async () => {
|
||||
plexServiceMock.switchHomeUser.mockResolvedValue('profile-token');
|
||||
prismaMock.user.count.mockResolvedValue(1);
|
||||
prismaMock.user.upsert.mockResolvedValue({
|
||||
id: 'user-2',
|
||||
plexId: 'uuid-1',
|
||||
plexUsername: 'Profile',
|
||||
plexEmail: null,
|
||||
role: 'user',
|
||||
avatarUrl: null,
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/auth/plex/switch-profile/route');
|
||||
const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' });
|
||||
request.json.mockResolvedValue({
|
||||
userId: 'home-1',
|
||||
pin: '1234',
|
||||
profileInfo: { uuid: 'uuid-1', friendlyName: 'Profile' },
|
||||
});
|
||||
|
||||
const response = await POST(request as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Component: BookDate Test Connection Route Tests
|
||||
* Documentation: documentation/features/bookdate-prd.md
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
decrypt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
describe('BookDate test connection route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('rejects unauthenticated use of saved keys', async () => {
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'openai', useSavedKey: true }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toMatch(/Authentication required/i);
|
||||
});
|
||||
|
||||
it('requires API key for OpenAI unauthenticated requests', async () => {
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'openai' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/API key is required/);
|
||||
});
|
||||
|
||||
it('requires baseUrl for custom unauthenticated requests', async () => {
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'custom' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Base URL is required/);
|
||||
});
|
||||
|
||||
it('requires provider for unauthenticated requests', async () => {
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('validates provider for unauthenticated requests', async () => {
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'invalid', apiKey: 'x' }),
|
||||
} as any);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns filtered OpenAI models for unauthenticated requests', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'gpt-4-1' },
|
||||
{ id: 'gpt-3.5-turbo' },
|
||||
{ id: 'gpt-4-0' },
|
||||
],
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'openai', apiKey: 'key' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.models.map((m: any) => m.id)).toEqual(['gpt-4-0', 'gpt-4-1']);
|
||||
});
|
||||
|
||||
it('returns filtered OpenAI models for authenticated requests', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'gpt-4-2' },
|
||||
{ id: 'gpt-3.5-turbo' },
|
||||
{ id: 'gpt-4-1' },
|
||||
],
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'openai', apiKey: 'key' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.models.map((m: any) => m.id)).toEqual(['gpt-4-1', 'gpt-4-2']);
|
||||
});
|
||||
|
||||
it('returns Claude models for unauthenticated requests', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'claude', apiKey: 'key' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.models.length).toBe(4);
|
||||
});
|
||||
|
||||
it('returns OpenAI error for unauthenticated requests with invalid key', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
text: vi.fn().mockResolvedValue('bad key'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'openai', apiKey: 'bad' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid OpenAI API key/i);
|
||||
});
|
||||
|
||||
it('returns error when saved config is missing', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValue(null);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'openai', useSavedKey: true }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/No saved configuration/i);
|
||||
});
|
||||
|
||||
it('returns error when saved key decryption fails', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValue({ apiKey: 'enc-key', baseUrl: null });
|
||||
encryptionMock.decrypt.mockImplementation(() => {
|
||||
throw new Error('decrypt failed');
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'openai', useSavedKey: true }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Failed to decrypt/i);
|
||||
});
|
||||
|
||||
it('requires API key for authenticated OpenAI requests', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'openai', apiKey: '' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/API key is required/);
|
||||
});
|
||||
|
||||
it('requires baseUrl when using saved custom config without baseUrl', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValue({
|
||||
apiKey: 'enc-key',
|
||||
baseUrl: null,
|
||||
});
|
||||
encryptionMock.decrypt.mockReturnValue('decrypted');
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'custom', useSavedKey: true }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/No saved base URL/i);
|
||||
});
|
||||
|
||||
it('uses saved key for custom provider and parses OpenAI format', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValue({
|
||||
apiKey: 'enc-key',
|
||||
baseUrl: 'http://custom',
|
||||
});
|
||||
encryptionMock.decrypt.mockReturnValue('decrypted');
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
data: [{ id: 'model-a', name: 'Model A' }],
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'custom', useSavedKey: true }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.models).toEqual([{ id: 'model-a', name: 'Model A' }]);
|
||||
});
|
||||
|
||||
it('validates custom base URLs for authenticated requests', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'ftp://bad' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid base URL/i);
|
||||
});
|
||||
|
||||
it('validates custom base URLs for unauthenticated requests', async () => {
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'ftp://bad' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid base URL/i);
|
||||
});
|
||||
|
||||
it('returns custom provider models for authenticated requests', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue([
|
||||
{ id: 'model-a' },
|
||||
{ id: 'model-b', name: 'Model B' },
|
||||
]),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: '' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.models).toEqual([
|
||||
{ id: 'model-a', name: 'model-a' },
|
||||
{ id: 'model-b', name: 'Model B' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns helpful message when custom models list cannot be parsed', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ unexpected: true }),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: 'key' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.message).toMatch(/could not parse models list/i);
|
||||
});
|
||||
|
||||
it('returns network error when custom provider fetch fails', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
|
||||
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: 'key' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(response.status).toBe(500);
|
||||
expect(payload.error).toMatch(/Network error/i);
|
||||
});
|
||||
|
||||
it('returns 400 (not 401) when custom provider returns 401 to prevent logout', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: vi.fn().mockResolvedValue('Unauthorized'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: 'bad-key' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
// Should return 400, not 401, to prevent fetchWithAuth from logging user out
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Failed to connect to custom provider/i);
|
||||
});
|
||||
|
||||
it('returns 400 (not 401) when custom provider returns 401 during unauthenticated request', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: vi.fn().mockResolvedValue('Unauthorized'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: 'bad-key' }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
// Should return 400, not 401, to prevent fetchWithAuth from logging user out
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Failed to connect to custom provider/i);
|
||||
});
|
||||
|
||||
it('allows custom provider when saved key decryption fails', async () => {
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(_req));
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValue({
|
||||
apiKey: 'enc-key',
|
||||
baseUrl: 'http://custom',
|
||||
});
|
||||
encryptionMock.decrypt.mockImplementation(() => {
|
||||
throw new Error('decrypt failed');
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue([{ id: 'model-a' }]),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => 'Bearer token' },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'custom', useSavedKey: true }),
|
||||
} as any);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.success).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://custom/models',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Component: BookDate API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const requireAdminMock = vi.hoisted(() => vi.fn());
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
encrypt: vi.fn((value: string) => `enc-${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace('enc-', '')),
|
||||
}));
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
}));
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addSearchJob: vi.fn(),
|
||||
}));
|
||||
const bookdateHelpersMock = vi.hoisted(() => ({
|
||||
buildAIPrompt: vi.fn(),
|
||||
callAI: vi.fn(),
|
||||
matchToAudnexus: vi.fn(),
|
||||
isInLibrary: vi.fn(),
|
||||
isAlreadyRequested: vi.fn(),
|
||||
isAlreadySwiped: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: requireAdminMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/bookdate/helpers', () => bookdateHelpersMock);
|
||||
|
||||
describe('BookDate routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = { user: { id: 'user-1', role: 'admin' }, json: vi.fn() };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
requireAdminMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
});
|
||||
|
||||
it('returns BookDate config without API key', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
|
||||
id: 'cfg-1',
|
||||
apiKey: 'secret',
|
||||
provider: 'openai',
|
||||
model: 'gpt',
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/config/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.config.apiKey).toBeUndefined();
|
||||
expect(payload.config.provider).toBe('openai');
|
||||
});
|
||||
|
||||
it('returns null config when not configured', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/config/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.config).toBeNull();
|
||||
});
|
||||
|
||||
it('saves BookDate config and clears recommendations', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.bookDateConfig.create.mockResolvedValueOnce({
|
||||
id: 'cfg-2',
|
||||
provider: 'openai',
|
||||
model: 'gpt',
|
||||
apiKey: 'enc-secret',
|
||||
});
|
||||
prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 });
|
||||
authRequest.json.mockResolvedValue({ provider: 'openai', apiKey: 'secret', model: 'gpt' });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/config/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.bookDateRecommendation.deleteMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects missing required fields when saving config', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
|
||||
authRequest.json.mockResolvedValue({ provider: 'openai', apiKey: 'key' });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/config/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Missing required fields/);
|
||||
});
|
||||
|
||||
it('rejects invalid provider when saving config', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
|
||||
authRequest.json.mockResolvedValue({ provider: 'invalid', apiKey: 'key', model: 'gpt' });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/config/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid provider/);
|
||||
});
|
||||
|
||||
it('rejects custom provider without baseUrl', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
|
||||
authRequest.json.mockResolvedValue({ provider: 'custom', apiKey: '', model: 'model-x' });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/config/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Base URL is required/);
|
||||
});
|
||||
|
||||
it('rejects custom provider with invalid baseUrl', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
|
||||
authRequest.json.mockResolvedValue({
|
||||
provider: 'custom',
|
||||
apiKey: '',
|
||||
model: 'model-x',
|
||||
baseUrl: 'ftp://bad',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/config/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid base URL/);
|
||||
});
|
||||
|
||||
it('updates existing config without a new API key', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
|
||||
id: 'cfg-9',
|
||||
apiKey: 'enc-existing',
|
||||
});
|
||||
prismaMock.bookDateConfig.update.mockResolvedValueOnce({
|
||||
id: 'cfg-9',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
apiKey: 'enc-existing',
|
||||
});
|
||||
authRequest.json.mockResolvedValue({ provider: 'openai', model: 'gpt-4' });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/config/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.bookDateConfig.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates custom config with empty API key', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.bookDateConfig.create.mockResolvedValueOnce({
|
||||
id: 'cfg-10',
|
||||
provider: 'custom',
|
||||
model: 'model-x',
|
||||
apiKey: 'enc-',
|
||||
});
|
||||
prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 });
|
||||
authRequest.json.mockResolvedValue({
|
||||
provider: 'custom',
|
||||
model: 'model-x',
|
||||
baseUrl: 'http://custom',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/config/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(encryptionMock.encrypt).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('deletes BookDate config', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ id: 'cfg-3' });
|
||||
prismaMock.bookDateConfig.delete.mockResolvedValueOnce({});
|
||||
prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 });
|
||||
prismaMock.bookDateSwipe.deleteMany.mockResolvedValueOnce({ count: 1 });
|
||||
|
||||
const { DELETE } = await import('@/app/api/bookdate/config/route');
|
||||
const response = await DELETE({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 404 when deleting missing BookDate config', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const { DELETE } = await import('@/app/api/bookdate/config/route');
|
||||
const response = await DELETE({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/Configuration not found/);
|
||||
});
|
||||
|
||||
it('returns BookDate preferences', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
bookDateLibraryScope: 'full',
|
||||
bookDateCustomPrompt: null,
|
||||
bookDateOnboardingComplete: true,
|
||||
});
|
||||
configServiceMock.getBackendMode.mockResolvedValueOnce('plex');
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/preferences/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.libraryScope).toBe('full');
|
||||
expect(payload.onboardingComplete).toBe(true);
|
||||
});
|
||||
|
||||
it('updates BookDate preferences', async () => {
|
||||
configServiceMock.getBackendMode.mockResolvedValueOnce('plex');
|
||||
prismaMock.user.update.mockResolvedValueOnce({
|
||||
bookDateLibraryScope: 'rated',
|
||||
bookDateCustomPrompt: 'Prompt',
|
||||
bookDateOnboardingComplete: true,
|
||||
});
|
||||
authRequest.json.mockResolvedValue({ libraryScope: 'rated', customPrompt: 'Prompt', onboardingComplete: true });
|
||||
|
||||
const { PUT } = await import('@/app/api/bookdate/preferences/route');
|
||||
const response = await PUT({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.libraryScope).toBe('rated');
|
||||
});
|
||||
|
||||
it('returns cached recommendations without calling AI', async () => {
|
||||
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([{ id: 'rec-1' }]);
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/recommendations/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.source).toBe('cache');
|
||||
expect(payload.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns error when recommendations are disabled', async () => {
|
||||
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
|
||||
isVerified: true,
|
||||
isEnabled: false,
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/recommendations/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/not configured/i);
|
||||
});
|
||||
|
||||
it('returns 404 when recommendation user is missing', async () => {
|
||||
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
|
||||
isVerified: true,
|
||||
isEnabled: true,
|
||||
provider: 'openai',
|
||||
model: 'gpt',
|
||||
apiKey: 'enc-key',
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/recommendations/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/User not found/i);
|
||||
});
|
||||
|
||||
it('generates and stores recommendations when AI returns matches', async () => {
|
||||
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]);
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
|
||||
isVerified: true,
|
||||
isEnabled: true,
|
||||
provider: 'openai',
|
||||
model: 'gpt',
|
||||
apiKey: 'enc-key',
|
||||
baseUrl: null,
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
bookDateLibraryScope: 'full',
|
||||
bookDateCustomPrompt: null,
|
||||
});
|
||||
bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}');
|
||||
bookdateHelpersMock.callAI.mockResolvedValueOnce({
|
||||
recommendations: [{ title: 'Title', author: 'Author', reason: 'Because' }],
|
||||
});
|
||||
bookdateHelpersMock.isAlreadySwiped.mockResolvedValue(false);
|
||||
bookdateHelpersMock.isInLibrary.mockResolvedValue(false);
|
||||
bookdateHelpersMock.matchToAudnexus.mockResolvedValueOnce({
|
||||
asin: 'ASIN1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
rating: null,
|
||||
description: null,
|
||||
coverUrl: null,
|
||||
});
|
||||
bookdateHelpersMock.isAlreadyRequested.mockResolvedValue(false);
|
||||
(prismaMock.bookDateRecommendation as any).createMany = vi.fn().mockResolvedValueOnce({ count: 1 });
|
||||
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([{ id: 'rec-1' }]);
|
||||
|
||||
const { GET } = await import('@/app/api/bookdate/recommendations/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.source).toBe('generated');
|
||||
expect(prismaMock.bookDateRecommendation.createMany).toHaveBeenCalled();
|
||||
expect(payload.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns error when generating recommendations without config', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/generate/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/not configured/);
|
||||
});
|
||||
|
||||
it('returns 404 when no new recommendations can be matched', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
|
||||
isVerified: true,
|
||||
isEnabled: true,
|
||||
provider: 'openai',
|
||||
model: 'gpt',
|
||||
apiKey: 'enc-key',
|
||||
baseUrl: null,
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
bookDateLibraryScope: 'full',
|
||||
bookDateCustomPrompt: null,
|
||||
});
|
||||
bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}');
|
||||
bookdateHelpersMock.callAI.mockResolvedValueOnce({
|
||||
recommendations: [{ title: 'Title', author: 'Author' }],
|
||||
});
|
||||
bookdateHelpersMock.isAlreadySwiped.mockResolvedValue(false);
|
||||
bookdateHelpersMock.isInLibrary.mockResolvedValue(false);
|
||||
bookdateHelpersMock.matchToAudnexus.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/generate/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/Could not find any new recommendations/i);
|
||||
});
|
||||
|
||||
it('stores generated recommendations from the AI', async () => {
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({
|
||||
isVerified: true,
|
||||
isEnabled: true,
|
||||
provider: 'openai',
|
||||
model: 'gpt',
|
||||
apiKey: 'enc-key',
|
||||
baseUrl: null,
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValueOnce({
|
||||
bookDateLibraryScope: 'full',
|
||||
bookDateCustomPrompt: null,
|
||||
});
|
||||
bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}');
|
||||
bookdateHelpersMock.callAI.mockResolvedValueOnce({
|
||||
recommendations: [{ title: 'Title', author: 'Author', reason: 'Because' }],
|
||||
});
|
||||
bookdateHelpersMock.isAlreadySwiped.mockResolvedValue(false);
|
||||
bookdateHelpersMock.isInLibrary.mockResolvedValue(false);
|
||||
bookdateHelpersMock.matchToAudnexus.mockResolvedValueOnce({
|
||||
asin: 'ASIN1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
rating: null,
|
||||
description: null,
|
||||
coverUrl: null,
|
||||
});
|
||||
bookdateHelpersMock.isAlreadyRequested.mockResolvedValue(false);
|
||||
(prismaMock.bookDateRecommendation as any).createMany = vi.fn().mockResolvedValueOnce({ count: 1 });
|
||||
prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([{ id: 'rec-2' }]);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/generate/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.source).toBe('generated');
|
||||
expect(prismaMock.bookDateRecommendation.createMany).toHaveBeenCalled();
|
||||
expect(payload.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('records swipe and creates request on right swipe', async () => {
|
||||
authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'right', markedAsKnown: false });
|
||||
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
|
||||
id: 'rec-1',
|
||||
userId: 'user-1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
audnexusAsin: 'ASIN',
|
||||
});
|
||||
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({});
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' });
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-1' });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/swipe/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('undoes last swipe', async () => {
|
||||
prismaMock.bookDateSwipe.findFirst.mockResolvedValueOnce({
|
||||
id: 'swipe-1',
|
||||
recommendation: { id: 'rec-1', createdAt: new Date() },
|
||||
});
|
||||
prismaMock.bookDateRecommendation.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.bookDateSwipe.delete.mockResolvedValueOnce({});
|
||||
prismaMock.bookDateRecommendation.update.mockResolvedValueOnce({ id: 'rec-1' });
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/undo/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('clears all swipes as admin', async () => {
|
||||
prismaMock.bookDateSwipe.deleteMany.mockResolvedValueOnce({ count: 1 });
|
||||
prismaMock.bookDateRecommendation.deleteMany.mockResolvedValueOnce({ count: 1 });
|
||||
|
||||
const { DELETE } = await import('@/app/api/bookdate/swipes/route');
|
||||
const response = await DELETE({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
});
|
||||
|
||||
it('tests BookDate connection without auth', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ data: [{ id: 'model-1' }] }),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/bookdate/test-connection/route');
|
||||
const response = await POST({
|
||||
headers: { get: () => null },
|
||||
json: vi.fn().mockResolvedValue({ provider: 'custom', baseUrl: 'http://custom', apiKey: '' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.models[0].id).toBe('model-1');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Component: Cache API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } }));
|
||||
|
||||
describe('Thumbnail cache route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('rejects invalid filenames', async () => {
|
||||
const { GET } = await import('@/app/api/cache/thumbnails/[filename]/route');
|
||||
|
||||
const response = await GET({} as any, { params: Promise.resolve({ filename: '../bad' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('Invalid filename');
|
||||
});
|
||||
|
||||
it('returns 404 when file is missing', async () => {
|
||||
fsMock.access.mockRejectedValueOnce(new Error('missing'));
|
||||
const { GET } = await import('@/app/api/cache/thumbnails/[filename]/route');
|
||||
|
||||
const response = await GET({} as any, { params: Promise.resolve({ filename: 'file.jpg' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('File not found');
|
||||
});
|
||||
|
||||
it('serves cached image with content type', async () => {
|
||||
fsMock.access.mockResolvedValueOnce(undefined);
|
||||
fsMock.readFile.mockResolvedValueOnce(Buffer.from('data'));
|
||||
const { GET } = await import('@/app/api/cache/thumbnails/[filename]/route');
|
||||
|
||||
const response = await GET({} as any, { params: Promise.resolve({ filename: 'file.png' }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Component: Config API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
setMany: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
getCategory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
describe('Config API routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns full configuration', async () => {
|
||||
configServiceMock.getAll.mockResolvedValue({ plex_url: 'http://plex' });
|
||||
const { GET } = await import('@/app/api/config/route');
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.config.plex_url).toBe('http://plex');
|
||||
});
|
||||
|
||||
it('updates configuration values', async () => {
|
||||
const { PUT } = await import('@/app/api/config/route');
|
||||
const response = await PUT({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
updates: [{ key: 'plex_url', value: 'http://plex' }],
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.updated).toBe(1);
|
||||
expect(configServiceMock.setMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns category configuration', async () => {
|
||||
configServiceMock.getCategory.mockResolvedValue({ plex_url: 'http://plex' });
|
||||
const { GET } = await import('@/app/api/config/[category]/route');
|
||||
|
||||
const response = await GET({} as any, { params: Promise.resolve({ category: 'plex' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.category).toBe('plex');
|
||||
expect(payload.config.plex_url).toBe('http://plex');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Component: Request Action API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const prowlarrMock = vi.hoisted(() => ({ search: vi.fn() }));
|
||||
const rankTorrentsMock = vi.hoisted(() => vi.fn());
|
||||
const configServiceMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const configState = vi.hoisted(() => ({
|
||||
values: new Map<string, string>(),
|
||||
}));
|
||||
const jobQueueMock = vi.hoisted(() => ({
|
||||
addSearchJob: vi.fn(),
|
||||
addDownloadJob: vi.fn(),
|
||||
}));
|
||||
const downloadEbookMock = vi.hoisted(() => vi.fn());
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
requireAdmin: vi.fn((req: any, handler: any) => handler()),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
||||
getProwlarrService: async () => prowlarrMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/ranking-algorithm', () => ({
|
||||
rankTorrents: rankTorrentsMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/ebook-scraper', () => ({
|
||||
downloadEbook: downloadEbookMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } }));
|
||||
|
||||
describe('Request action routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configState.values.clear();
|
||||
authRequest = { user: { id: 'user-1', role: 'user' }, json: vi.fn() };
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
prismaMock.configuration.findUnique.mockImplementation(
|
||||
async ({ where: { key } }: { where: { key: string } }) => {
|
||||
const value = configState.values.get(key);
|
||||
return value !== undefined ? { value } : null;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('performs interactive search and ranks results', async () => {
|
||||
authRequest.json.mockResolvedValue({});
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-1',
|
||||
userId: 'user-1',
|
||||
audiobook: { title: 'Title', author: 'Author' },
|
||||
});
|
||||
configServiceMock.get.mockResolvedValueOnce(JSON.stringify([{ id: 1, priority: 10 }]));
|
||||
configServiceMock.get.mockResolvedValueOnce(null);
|
||||
prowlarrMock.search.mockResolvedValueOnce([{ title: 'Result', size: 100 }]);
|
||||
rankTorrentsMock.mockReturnValueOnce([
|
||||
{ title: 'Result', score: 50, breakdown: { matchScore: 50, formatScore: 0, seederScore: 0, notes: [] }, bonusPoints: 0, bonusModifiers: [], finalScore: 50 },
|
||||
]);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/interactive-search/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.results[0].rank).toBe(1);
|
||||
});
|
||||
|
||||
it('triggers manual search job', async () => {
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-2',
|
||||
userId: 'user-1',
|
||||
status: 'failed',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValueOnce({ id: 'req-2', status: 'pending' });
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/manual-search/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects a torrent and queues download', async () => {
|
||||
authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } });
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-3',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-2', title: 'Title', author: 'Author' },
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValueOnce({ id: 'req-3', status: 'downloading' });
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when ebook sidecar is disabled', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/not enabled/);
|
||||
});
|
||||
|
||||
it('returns 404 when request is not found', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toMatch(/not found/);
|
||||
});
|
||||
|
||||
it('returns 400 when request status is not eligible for ebook fetch', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-5',
|
||||
status: 'pending',
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-5' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Cannot fetch e-book/);
|
||||
});
|
||||
|
||||
it('returns 400 when audiobook directory is missing', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-6',
|
||||
status: 'downloaded',
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN' },
|
||||
});
|
||||
fsMock.access.mockRejectedValueOnce(new Error('missing'));
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-6' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/directory not found/);
|
||||
});
|
||||
|
||||
it('downloads ebook and returns success', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
configState.values.set('media_dir', '/media/audiobooks');
|
||||
configState.values.set('ebook_sidecar_preferred_format', 'epub');
|
||||
configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example');
|
||||
configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr');
|
||||
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-7',
|
||||
status: 'available',
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
||||
});
|
||||
prismaMock.audibleCache.findUnique.mockResolvedValueOnce({ releaseDate: '2022-05-01' });
|
||||
fsMock.access.mockResolvedValueOnce(undefined);
|
||||
downloadEbookMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
format: 'epub',
|
||||
filePath: '/media/audiobooks/Author/Title (2022) ASIN123/Title.epub',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-7' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(downloadEbookMock).toHaveBeenCalledWith(
|
||||
'ASIN123',
|
||||
'Title',
|
||||
'Author',
|
||||
expect.stringContaining('Title (2022) ASIN123'),
|
||||
'epub',
|
||||
'https://ebooks.example',
|
||||
undefined,
|
||||
'http://flaresolverr'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns failure payload when ebook download fails', async () => {
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
prismaMock.request.findUnique.mockResolvedValueOnce({
|
||||
id: 'req-8',
|
||||
status: 'downloaded',
|
||||
audiobook: { title: 'Title', author: 'Author', audibleAsin: 'ASIN123' },
|
||||
});
|
||||
fsMock.access.mockResolvedValueOnce(undefined);
|
||||
downloadEbookMock.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Download failed',
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/[id]/fetch-ebook/route');
|
||||
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-8' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(false);
|
||||
expect(payload.message).toMatch(/Download failed/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Component: Request By ID API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn(), addOrganizeJob: vi.fn() }));
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
|
||||
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
getQBittorrentService: async () => qbtMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
getSABnzbdService: async () => sabnzbdMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
describe('Request by ID API routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = {
|
||||
user: { id: 'user-1', role: 'user' },
|
||||
nextUrl: new URL('http://localhost/api/requests/req-1'),
|
||||
json: vi.fn(),
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
});
|
||||
|
||||
it('returns 403 when user is not authorized to view the request', async () => {
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'req-1',
|
||||
userId: 'user-2',
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('Forbidden');
|
||||
});
|
||||
|
||||
it('returns request details for the owner', async () => {
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'req-1',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-1' },
|
||||
});
|
||||
|
||||
const { GET } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.request.id).toBe('req-1');
|
||||
});
|
||||
|
||||
it('returns 404 when request does not exist', async () => {
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const { GET } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await GET({} as any, { params: Promise.resolve({ id: 'missing' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(payload.error).toBe('NotFound');
|
||||
});
|
||||
|
||||
it('returns 401 when user is missing', async () => {
|
||||
authRequest.user = null;
|
||||
|
||||
const { GET } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await GET({} as any, { params: Promise.resolve({ id: 'req-1' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('cancels a request', async () => {
|
||||
authRequest.json.mockResolvedValue({ action: 'cancel' });
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'req-2',
|
||||
userId: 'user-1',
|
||||
status: 'pending',
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-2',
|
||||
status: 'cancelled',
|
||||
audiobook: { id: 'ab-1' },
|
||||
});
|
||||
|
||||
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.request.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid actions', async () => {
|
||||
authRequest.json.mockResolvedValue({ action: 'unknown' });
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'req-2',
|
||||
userId: 'user-1',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-2' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('rejects retry when status is not retryable', async () => {
|
||||
authRequest.json.mockResolvedValue({ action: 'retry' });
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'req-4',
|
||||
userId: 'user-1',
|
||||
status: 'available',
|
||||
});
|
||||
|
||||
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('retries a failed request by enqueuing a search job', async () => {
|
||||
authRequest.json.mockResolvedValue({ action: 'retry' });
|
||||
prismaMock.request.findFirst
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req-3',
|
||||
userId: 'user-1',
|
||||
status: 'failed',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req-3',
|
||||
userId: 'user-1',
|
||||
audiobook: {
|
||||
id: 'ab-2',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
audibleAsin: 'ASIN-2',
|
||||
},
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-3',
|
||||
status: 'pending',
|
||||
audiobook: { id: 'ab-2' },
|
||||
});
|
||||
|
||||
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-3' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-3', {
|
||||
id: 'ab-2',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
asin: 'ASIN-2',
|
||||
});
|
||||
});
|
||||
|
||||
it('retries an import via qBittorrent download history', async () => {
|
||||
authRequest.json.mockResolvedValue({ action: 'retry' });
|
||||
prismaMock.request.findFirst
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req-5',
|
||||
userId: 'user-1',
|
||||
status: 'warn',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req-5',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-5' },
|
||||
downloadHistory: [{ torrentHash: 'hash-1', selected: true }],
|
||||
});
|
||||
qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' });
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-5',
|
||||
status: 'processing',
|
||||
audiobook: { id: 'ab-5' },
|
||||
});
|
||||
|
||||
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-5' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith('req-5', 'ab-5', '/downloads/Book');
|
||||
});
|
||||
|
||||
it('retries an import via SABnzbd download history', async () => {
|
||||
authRequest.json.mockResolvedValue({ action: 'retry' });
|
||||
prismaMock.request.findFirst
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req-6',
|
||||
userId: 'user-1',
|
||||
status: 'awaiting_import',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req-6',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-6' },
|
||||
downloadHistory: [{ nzbId: 'nzb-1', selected: true }],
|
||||
});
|
||||
sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/usenet/book' });
|
||||
prismaMock.request.update.mockResolvedValueOnce({
|
||||
id: 'req-6',
|
||||
status: 'processing',
|
||||
audiobook: { id: 'ab-6' },
|
||||
});
|
||||
|
||||
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-6' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith('req-6', 'ab-6', '/usenet/book');
|
||||
});
|
||||
|
||||
it('returns 400 when download history is missing for import retry', async () => {
|
||||
authRequest.json.mockResolvedValue({ action: 'retry' });
|
||||
prismaMock.request.findFirst
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req-7',
|
||||
userId: 'user-1',
|
||||
status: 'warn',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req-7',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-7' },
|
||||
downloadHistory: [],
|
||||
});
|
||||
|
||||
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-7' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('returns 400 when download client info is missing for import retry', async () => {
|
||||
authRequest.json.mockResolvedValue({ action: 'retry' });
|
||||
prismaMock.request.findFirst
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req-8',
|
||||
userId: 'user-1',
|
||||
status: 'warn',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'req-8',
|
||||
userId: 'user-1',
|
||||
audiobook: { id: 'ab-8' },
|
||||
downloadHistory: [{}],
|
||||
});
|
||||
|
||||
const { PATCH } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await PATCH({} as any, { params: Promise.resolve({ id: 'req-8' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toBe('ValidationError');
|
||||
});
|
||||
|
||||
it('allows admins to delete requests', async () => {
|
||||
authRequest.user = { id: 'admin-1', role: 'admin' };
|
||||
prismaMock.request.delete.mockResolvedValueOnce({});
|
||||
|
||||
const { DELETE } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-4' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-4' } });
|
||||
});
|
||||
|
||||
it('blocks delete for non-admin users', async () => {
|
||||
authRequest.user = { id: 'user-2', role: 'user' };
|
||||
|
||||
const { DELETE } = await import('@/app/api/requests/[id]/route');
|
||||
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-9' }) });
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(payload.error).toBe('Forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Component: Requests API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
let authRequest: any;
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = vi.hoisted(() => ({ addSearchJob: vi.fn() }));
|
||||
const findPlexMatchMock = vi.hoisted(() => vi.fn());
|
||||
const requireAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
findPlexMatch: findPlexMatchMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/middleware/auth', () => ({
|
||||
requireAuth: requireAuthMock,
|
||||
}));
|
||||
|
||||
describe('Requests API routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authRequest = {
|
||||
user: { id: 'user-1', role: 'user' },
|
||||
nextUrl: new URL('http://localhost/api/requests'),
|
||||
json: vi.fn(),
|
||||
};
|
||||
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
|
||||
});
|
||||
|
||||
it('returns 409 when an active request already exists', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN-1', title: 'Title', author: 'Author' },
|
||||
});
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({
|
||||
id: 'req-1',
|
||||
status: 'downloaded',
|
||||
userId: 'user-2',
|
||||
user: { plexUsername: 'someone' },
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(payload.error).toBe('BeingProcessed');
|
||||
expect(findPlexMatchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 409 when a Plex match already exists', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN-2', title: 'Title', author: 'Author' },
|
||||
});
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
findPlexMatchMock.mockResolvedValueOnce({ plexGuid: 'plex-1' });
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(payload.error).toBe('AlreadyAvailable');
|
||||
});
|
||||
|
||||
it('creates a new request and enqueues a search job', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN-3', title: 'Title', author: 'Author' },
|
||||
});
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
findPlexMatchMock.mockResolvedValueOnce(null);
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.audiobook.create.mockResolvedValueOnce({
|
||||
id: 'ab-1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
audibleAsin: 'ASIN-3',
|
||||
});
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'req-2',
|
||||
audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN-3' },
|
||||
user: { id: 'user-1', plexUsername: 'user' },
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
const response = await POST({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith('req-2', {
|
||||
id: 'ab-1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
asin: 'ASIN-3',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips auto-search when skipAutoSearch=true', async () => {
|
||||
authRequest.nextUrl = new URL('http://localhost/api/requests?skipAutoSearch=true');
|
||||
authRequest.json.mockResolvedValue({
|
||||
audiobook: { asin: 'ASIN-4', title: 'Title', author: 'Author' },
|
||||
});
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
findPlexMatchMock.mockResolvedValueOnce(null);
|
||||
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.audiobook.create.mockResolvedValueOnce({
|
||||
id: 'ab-2',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
audibleAsin: 'ASIN-4',
|
||||
});
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.request.create.mockResolvedValueOnce({
|
||||
id: 'req-3',
|
||||
audiobook: { id: 'ab-2', title: 'Title', author: 'Author', audibleAsin: 'ASIN-4' },
|
||||
user: { id: 'user-1', plexUsername: 'user' },
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/requests/route');
|
||||
await POST({} as any);
|
||||
|
||||
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
|
||||
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'awaiting_search' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('filters requests for current user when not admin', async () => {
|
||||
authRequest.nextUrl = new URL('http://localhost/api/requests?status=pending&limit=5');
|
||||
prismaMock.request.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const { GET } = await import('@/app/api/requests/route');
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(prismaMock.request.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ userId: 'user-1', status: 'pending' }),
|
||||
take: 5,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Component: Setup Validation API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const plexServiceMock = vi.hoisted(() => ({
|
||||
testConnection: vi.fn(),
|
||||
getLibraries: vi.fn(),
|
||||
}));
|
||||
const qbtMock = vi.hoisted(() => ({
|
||||
testConnectionWithCredentials: vi.fn(),
|
||||
}));
|
||||
const sabnzbdMock = vi.hoisted(() => ({
|
||||
testConnection: vi.fn(),
|
||||
}));
|
||||
const prowlarrMock = vi.hoisted(() => ({
|
||||
getIndexers: vi.fn(),
|
||||
}));
|
||||
const issuerMock = vi.hoisted(() => ({
|
||||
discover: vi.fn(),
|
||||
}));
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/plex.service', () => ({
|
||||
getPlexService: () => plexServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
QBittorrentService: {
|
||||
testConnectionWithCredentials: qbtMock.testConnectionWithCredentials,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
SABnzbdService: class {
|
||||
constructor() {}
|
||||
testConnection = sabnzbdMock.testConnection;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
||||
ProwlarrService: class {
|
||||
constructor() {}
|
||||
getIndexers = prowlarrMock.getIndexers;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('openid-client', () => ({
|
||||
Issuer: issuerMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({ default: fsMock, ...fsMock, constants: { R_OK: 4 } }));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
describe('Setup test routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('validates Plex connection and returns libraries', async () => {
|
||||
plexServiceMock.testConnection.mockResolvedValue({
|
||||
success: true,
|
||||
info: { platform: 'Plex', version: '1.0', machineIdentifier: 'machine' },
|
||||
});
|
||||
plexServiceMock.getLibraries.mockResolvedValue([{ id: '1', title: 'Books', type: 'book' }]);
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-plex/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://plex', token: 'token' }) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.libraries[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('tests qBittorrent credentials', async () => {
|
||||
qbtMock.testConnectionWithCredentials.mockResolvedValue('4.0.0');
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-download-client/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.version).toBe('4.0.0');
|
||||
});
|
||||
|
||||
it('tests SABnzbd connection', async () => {
|
||||
sabnzbdMock.testConnection.mockResolvedValue({ success: true, version: '3.0' });
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-download-client/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
type: 'sabnzbd',
|
||||
url: 'http://sab',
|
||||
password: 'api-key',
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.version).toBe('3.0');
|
||||
});
|
||||
|
||||
it('tests Prowlarr indexers', async () => {
|
||||
prowlarrMock.getIndexers.mockResolvedValue([
|
||||
{ id: 1, name: 'Indexer', protocol: 'torrent', enable: true, capabilities: {} },
|
||||
{ id: 2, name: 'Disabled', protocol: 'torrent', enable: false, capabilities: {} },
|
||||
]);
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-prowlarr/route');
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.indexerCount).toBe(1);
|
||||
});
|
||||
|
||||
it('validates OIDC issuer discovery', async () => {
|
||||
issuerMock.discover.mockResolvedValue({
|
||||
issuer: 'http://issuer',
|
||||
metadata: {
|
||||
authorization_endpoint: 'http://issuer/auth',
|
||||
token_endpoint: 'http://issuer/token',
|
||||
userinfo_endpoint: 'http://issuer/user',
|
||||
jwks_uri: 'http://issuer/jwks',
|
||||
scopes_supported: ['openid'],
|
||||
response_types_supported: ['code'],
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-oidc/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
issuerUrl: 'http://issuer',
|
||||
clientId: 'client',
|
||||
clientSecret: 'secret',
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.issuer.authorizationEndpoint).toBe('http://issuer/auth');
|
||||
});
|
||||
|
||||
it('validates paths are writable', async () => {
|
||||
fsMock.access.mockRejectedValueOnce(new Error('missing'));
|
||||
fsMock.mkdir.mockResolvedValueOnce(undefined);
|
||||
fsMock.writeFile.mockResolvedValueOnce(undefined);
|
||||
fsMock.unlink.mockResolvedValueOnce(undefined);
|
||||
|
||||
fsMock.access.mockResolvedValueOnce(undefined);
|
||||
fsMock.writeFile.mockResolvedValueOnce(undefined);
|
||||
fsMock.unlink.mockResolvedValueOnce(undefined);
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-paths/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({ downloadDir: '/downloads', mediaDir: '/media' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.downloadDirValid).toBe(true);
|
||||
});
|
||||
|
||||
it('tests Audiobookshelf connection with saved token', async () => {
|
||||
configServiceMock.get.mockResolvedValueOnce('token');
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({ libraries: [{ id: '1', name: 'Lib', mediaType: 'book', stats: { totalItems: 10 } }] }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { POST } = await import('@/app/api/setup/test-abs/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({ serverUrl: 'http://abs', apiToken: '********' }),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.libraries[0].id).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Component: Setup API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const bcryptMock = vi.hoisted(() => ({
|
||||
hash: vi.fn(),
|
||||
}));
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
encrypt: vi.fn((value: string) => `enc-${value}`),
|
||||
}));
|
||||
const generateAccessTokenMock = vi.hoisted(() => vi.fn(() => 'access-token'));
|
||||
const generateRefreshTokenMock = vi.hoisted(() => vi.fn(() => 'refresh-token'));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('bcrypt', () => ({
|
||||
default: bcryptMock,
|
||||
...bcryptMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/encryption.service', () => ({
|
||||
getEncryptionService: () => encryptionMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/jwt', () => ({
|
||||
generateAccessToken: generateAccessTokenMock,
|
||||
generateRefreshToken: generateRefreshTokenMock,
|
||||
}));
|
||||
|
||||
describe('Setup routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns setup status from configuration', async () => {
|
||||
prismaMock.configuration.findUnique.mockResolvedValueOnce({ value: 'true' });
|
||||
const { GET } = await import('@/app/api/setup/status/route');
|
||||
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.setupComplete).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid backend mode on setup completion', async () => {
|
||||
const { POST } = await import('@/app/api/setup/complete/route');
|
||||
|
||||
const response = await POST({ json: vi.fn().mockResolvedValue({ backendMode: 'invalid' }) } as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(payload.error).toMatch(/Invalid or missing backend mode/);
|
||||
});
|
||||
|
||||
it('completes setup for Plex mode and returns tokens', async () => {
|
||||
bcryptMock.hash.mockResolvedValue('hashed');
|
||||
prismaMock.user.create.mockResolvedValue({
|
||||
id: 'admin-1',
|
||||
plexId: 'local-admin',
|
||||
plexUsername: 'admin',
|
||||
plexEmail: null,
|
||||
role: 'admin',
|
||||
avatarUrl: null,
|
||||
});
|
||||
prismaMock.configuration.upsert.mockResolvedValue({});
|
||||
prismaMock.scheduledJob.updateMany.mockResolvedValue({ count: 1 });
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValue(null);
|
||||
|
||||
const { POST } = await import('@/app/api/setup/complete/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
backendMode: 'plex',
|
||||
admin: { username: 'admin', password: 'pass' },
|
||||
plex: { url: 'http://plex', token: 'token', audiobook_library_id: 'lib', machine_identifier: 'machine' },
|
||||
prowlarr: { url: 'http://prowlarr', api_key: 'key', indexers: [{ id: 1 }] },
|
||||
downloadClient: { type: 'qbittorrent', url: 'http://qbt', username: 'u', password: 'p' },
|
||||
paths: { download_dir: '/downloads', media_dir: '/media' },
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
expect(prismaMock.configuration.upsert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes setup for Audiobookshelf with both auth methods', async () => {
|
||||
bcryptMock.hash.mockResolvedValue('hashed');
|
||||
prismaMock.user.create.mockResolvedValue({
|
||||
id: 'admin-2',
|
||||
plexId: 'local-admin',
|
||||
plexUsername: 'admin',
|
||||
plexEmail: null,
|
||||
role: 'admin',
|
||||
avatarUrl: null,
|
||||
});
|
||||
prismaMock.configuration.upsert.mockResolvedValue({});
|
||||
prismaMock.scheduledJob.updateMany.mockResolvedValue({ count: 1 });
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValue({ id: 'bookdate-1' });
|
||||
prismaMock.bookDateConfig.update.mockResolvedValue({});
|
||||
|
||||
const { POST } = await import('@/app/api/setup/complete/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
backendMode: 'audiobookshelf',
|
||||
admin: { username: 'admin', password: 'pass' },
|
||||
authMethod: 'both',
|
||||
audiobookshelf: {
|
||||
server_url: 'http://abs',
|
||||
api_token: 'abs-token',
|
||||
library_id: 'lib',
|
||||
trigger_scan_after_import: true,
|
||||
},
|
||||
oidc: {
|
||||
provider_name: 'OIDC',
|
||||
issuer_url: 'https://issuer',
|
||||
client_id: 'client-id',
|
||||
client_secret: 'client-secret',
|
||||
access_control_method: 'open',
|
||||
access_group_claim: 'groups',
|
||||
access_group_value: '',
|
||||
allowed_emails: '[]',
|
||||
allowed_usernames: '[]',
|
||||
admin_claim_enabled: 'true',
|
||||
admin_claim_name: 'groups',
|
||||
admin_claim_value: 'admins',
|
||||
},
|
||||
registration: { require_admin_approval: true },
|
||||
prowlarr: { url: 'http://prowlarr', api_key: 'key', indexers: [{ id: 1 }] },
|
||||
downloadClient: {
|
||||
type: 'qbittorrent',
|
||||
url: 'http://qbt',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
disableSSLVerify: true,
|
||||
remotePathMappingEnabled: true,
|
||||
remotePath: '/remote',
|
||||
localPath: '/local',
|
||||
},
|
||||
paths: {
|
||||
download_dir: '/downloads',
|
||||
media_dir: '/media',
|
||||
metadata_tagging_enabled: false,
|
||||
chapter_merging_enabled: true,
|
||||
},
|
||||
bookdate: { provider: 'openai', apiKey: 'bd-key', model: 'gpt-4' },
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.accessToken).toBe('access-token');
|
||||
expect(prismaMock.bookDateConfig.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes setup for Audiobookshelf without admin user', async () => {
|
||||
prismaMock.configuration.upsert.mockResolvedValue({});
|
||||
prismaMock.scheduledJob.updateMany.mockResolvedValue({ count: 1 });
|
||||
prismaMock.bookDateConfig.findFirst.mockResolvedValue(null);
|
||||
|
||||
const { POST } = await import('@/app/api/setup/complete/route');
|
||||
const response = await POST({
|
||||
json: vi.fn().mockResolvedValue({
|
||||
backendMode: 'audiobookshelf',
|
||||
authMethod: 'oidc',
|
||||
audiobookshelf: {
|
||||
server_url: 'http://abs',
|
||||
api_token: 'abs-token',
|
||||
library_id: 'lib',
|
||||
},
|
||||
oidc: {
|
||||
provider_name: 'OIDC',
|
||||
issuer_url: 'https://issuer',
|
||||
client_id: 'client-id',
|
||||
client_secret: 'client-secret',
|
||||
},
|
||||
prowlarr: { url: 'http://prowlarr', api_key: 'key', indexers: [{ id: 1 }] },
|
||||
downloadClient: { type: 'qbittorrent', url: 'http://qbt', username: 'u', password: 'p' },
|
||||
paths: { download_dir: '/downloads', media_dir: '/media' },
|
||||
}),
|
||||
} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.accessToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Component: System API Route Tests
|
||||
* Documentation: documentation/testing.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const schedulerMock = vi.hoisted(() => ({
|
||||
start: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/scheduler.service', () => ({
|
||||
getSchedulerService: () => schedulerMock,
|
||||
}));
|
||||
|
||||
describe('System routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns healthy status when database is reachable', async () => {
|
||||
prismaMock.$queryRaw.mockResolvedValueOnce(1);
|
||||
const { GET } = await import('@/app/api/health/route');
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.status).toBe('healthy');
|
||||
expect(payload.database).toBe('connected');
|
||||
});
|
||||
|
||||
it('returns unhealthy status on database error', async () => {
|
||||
prismaMock.$queryRaw.mockRejectedValueOnce(new Error('db down'));
|
||||
const { GET } = await import('@/app/api/health/route');
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(payload.status).toBe('unhealthy');
|
||||
});
|
||||
|
||||
it('initializes scheduler on init endpoint', async () => {
|
||||
const { GET } = await import('@/app/api/init/route');
|
||||
|
||||
const response = await GET({} as any);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(schedulerMock.start).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns version info from environment', async () => {
|
||||
process.env.APP_VERSION = 'abcdef123456';
|
||||
process.env.BUILD_DATE = '2025-01-01';
|
||||
|
||||
const { GET } = await import('@/app/api/version/route');
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(payload.shortCommit).toBe('abcdef1');
|
||||
expect(payload.buildDate).toBe('2025-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* Component: BookDate Helper Tests
|
||||
* Documentation: documentation/features/bookdate-prd.md
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getPlexConfig: vi.fn(),
|
||||
}));
|
||||
const encryptionMock = vi.hoisted(() => ({
|
||||
decrypt: vi.fn(),
|
||||
}));
|
||||
const plexMock = vi.hoisted(() => ({
|
||||
getServerAccessToken: vi.fn(),
|
||||
getLibraryContent: vi.fn(),
|
||||
}));
|
||||
const findPlexMatchMock = vi.hoisted(() => vi.fn());
|
||||
const loggerMock = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
}));
|
||||
const audibleState = vi.hoisted(() => ({
|
||||
instance: {
|
||||
search: vi.fn(),
|
||||
getAudiobookDetails: vi.fn(),
|
||||
},
|
||||
ctor: 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: () => plexMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
AudibleService: audibleState.ctor,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
findPlexMatch: findPlexMatchMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/logger', () => ({
|
||||
RMABLogger: {
|
||||
create: loggerMock.create,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BookDate helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loggerMock.create.mockReturnValue({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
});
|
||||
audibleState.ctor.mockImplementation(function () {
|
||||
return audibleState.instance;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns empty library when audiobookshelf has no library id', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue(null);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'rated');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prismaMock.plexLibrary.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps audiobookshelf cached books without ratings', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib-1');
|
||||
prismaMock.user.findUnique.mockResolvedValue({ plexId: 'local-1' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: 'Narr',
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: '7',
|
||||
},
|
||||
]);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'full');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: 'Narr',
|
||||
rating: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns rated books for local admin Plex users', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ plexId: 'local-1' })
|
||||
.mockResolvedValueOnce({ authToken: 'token', plexId: 'local-1', role: 'admin' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Rated',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid-1',
|
||||
plexRatingKey: 'rk-1',
|
||||
userRating: '9',
|
||||
},
|
||||
{
|
||||
title: 'Unrated',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid-2',
|
||||
plexRatingKey: 'rk-2',
|
||||
userRating: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'rated');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('Rated');
|
||||
expect(result[0].rating).toBe(9);
|
||||
});
|
||||
|
||||
it('returns rated books for Plex users with personal ratings', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
libraryId: 'plex-lib',
|
||||
serverUrl: 'http://plex',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ plexId: 'plex-1' })
|
||||
.mockResolvedValueOnce({ authToken: 'enc-token', plexId: 'plex-1', role: 'user' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Rated Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid-1',
|
||||
plexRatingKey: 'rk-1',
|
||||
userRating: null,
|
||||
},
|
||||
{
|
||||
title: 'Unrated',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid-2',
|
||||
plexRatingKey: 'rk-2',
|
||||
userRating: null,
|
||||
},
|
||||
]);
|
||||
encryptionMock.decrypt.mockReturnValue('user-token');
|
||||
plexMock.getServerAccessToken.mockResolvedValue('server-token');
|
||||
plexMock.getLibraryContent.mockResolvedValue([
|
||||
{ guid: 'guid-1', ratingKey: 'rk-1', userRating: 8 },
|
||||
{ guid: 'guid-2', ratingKey: 'rk-2' },
|
||||
]);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'rated');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Rated Book',
|
||||
author: 'Author',
|
||||
narrator: undefined,
|
||||
rating: 8,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to cached books when user token is missing', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ plexId: 'plex-2' })
|
||||
.mockResolvedValueOnce({ authToken: null, plexId: 'plex-2', role: 'user' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'full');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: undefined,
|
||||
rating: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty list when Plex library id is missing', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: null });
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'full');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prismaMock.plexLibrary.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to cached books when Plex server URL is missing', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ plexId: 'plex-3' })
|
||||
.mockResolvedValueOnce({ authToken: 'token', plexId: 'plex-3', role: 'user' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'full');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: undefined,
|
||||
rating: undefined,
|
||||
},
|
||||
]);
|
||||
expect(plexMock.getServerAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses plaintext token when decryption fails', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
libraryId: 'plex-lib',
|
||||
serverUrl: 'http://plex',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ plexId: 'plex-4' })
|
||||
.mockResolvedValueOnce({ authToken: 'plain-token', plexId: 'plex-4', role: 'user' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Rated Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid-1',
|
||||
plexRatingKey: 'rk-1',
|
||||
userRating: null,
|
||||
},
|
||||
]);
|
||||
encryptionMock.decrypt.mockImplementation(() => {
|
||||
throw new Error('decrypt failed');
|
||||
});
|
||||
plexMock.getServerAccessToken.mockResolvedValue('server-token');
|
||||
plexMock.getLibraryContent.mockResolvedValue([
|
||||
{ guid: 'guid-1', ratingKey: 'rk-1', userRating: 7 },
|
||||
]);
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'rated');
|
||||
|
||||
expect(result[0].rating).toBe(7);
|
||||
expect(plexMock.getServerAccessToken).toHaveBeenCalledWith('machine', 'plain-token');
|
||||
});
|
||||
|
||||
it('returns cached books when machine identifier is missing', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
libraryId: 'plex-lib',
|
||||
serverUrl: 'http://plex',
|
||||
machineIdentifier: null,
|
||||
});
|
||||
prismaMock.user.findUnique
|
||||
.mockResolvedValueOnce({ plexId: 'plex-5' })
|
||||
.mockResolvedValueOnce({ authToken: 'enc-token', plexId: 'plex-5', role: 'user' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: null,
|
||||
},
|
||||
]);
|
||||
encryptionMock.decrypt.mockReturnValue('user-token');
|
||||
|
||||
const { getUserLibraryBooks } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserLibraryBooks('user-1', 'full');
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: undefined,
|
||||
rating: undefined,
|
||||
},
|
||||
]);
|
||||
expect(plexMock.getServerAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds recent swipe history from prioritized swipes', async () => {
|
||||
const now = new Date();
|
||||
const older = new Date(now.getTime() - 1000);
|
||||
|
||||
prismaMock.bookDateSwipe.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
bookTitle: 'Latest',
|
||||
bookAuthor: 'Author',
|
||||
action: 'right',
|
||||
markedAsKnown: false,
|
||||
createdAt: now,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
bookTitle: 'Older',
|
||||
bookAuthor: 'Author',
|
||||
action: 'up',
|
||||
markedAsKnown: false,
|
||||
createdAt: older,
|
||||
},
|
||||
]);
|
||||
|
||||
const { getUserRecentSwipes } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserRecentSwipes('user-1', 2);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ title: 'Latest', author: 'Author', action: 'right', markedAsKnown: false },
|
||||
{ title: 'Older', author: 'Author', action: 'up', markedAsKnown: false },
|
||||
]);
|
||||
expect(prismaMock.bookDateSwipe.findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips dismiss lookup when limit is filled by non-dismiss swipes', async () => {
|
||||
prismaMock.bookDateSwipe.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
bookTitle: 'Recent',
|
||||
bookAuthor: 'Author',
|
||||
action: 'right',
|
||||
markedAsKnown: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
const { getUserRecentSwipes } = await import('@/lib/bookdate/helpers');
|
||||
const result = await getUserRecentSwipes('user-1', 1);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ title: 'Recent', author: 'Author', action: 'right', markedAsKnown: true },
|
||||
]);
|
||||
expect(prismaMock.bookDateSwipe.findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('builds AI prompt with mapped swipe actions', async () => {
|
||||
const now = new Date();
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib-1');
|
||||
prismaMock.user.findUnique.mockResolvedValue({ plexId: 'local-1' });
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
title: 'Lib',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
plexGuid: 'guid',
|
||||
plexRatingKey: 'rk',
|
||||
userRating: '8',
|
||||
},
|
||||
]);
|
||||
prismaMock.bookDateSwipe.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
bookTitle: 'Known',
|
||||
bookAuthor: 'Author',
|
||||
action: 'right',
|
||||
markedAsKnown: true,
|
||||
createdAt: now,
|
||||
},
|
||||
{
|
||||
bookTitle: 'Requested',
|
||||
bookAuthor: 'Author',
|
||||
action: 'right',
|
||||
markedAsKnown: false,
|
||||
createdAt: new Date(now.getTime() - 1000),
|
||||
},
|
||||
{
|
||||
bookTitle: 'Rejected',
|
||||
bookAuthor: 'Author',
|
||||
action: 'left',
|
||||
markedAsKnown: false,
|
||||
createdAt: new Date(now.getTime() - 2000),
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
bookTitle: 'Dismissed',
|
||||
bookAuthor: 'Author',
|
||||
action: 'up',
|
||||
markedAsKnown: false,
|
||||
createdAt: new Date(now.getTime() - 3000),
|
||||
},
|
||||
]);
|
||||
|
||||
const { buildAIPrompt } = await import('@/lib/bookdate/helpers');
|
||||
const prompt = await buildAIPrompt('user-1', { libraryScope: 'full', customPrompt: 'prefs' });
|
||||
const parsed = JSON.parse(prompt);
|
||||
|
||||
expect(parsed.user_context.library_books).toHaveLength(1);
|
||||
expect(parsed.user_context.swipe_history).toEqual([
|
||||
{ title: 'Known', author: 'Author', user_action: 'marked_as_liked' },
|
||||
{ title: 'Requested', author: 'Author', user_action: 'requested' },
|
||||
{ title: 'Rejected', author: 'Author', user_action: 'rejected' },
|
||||
{ title: 'Dismissed', author: 'Author', user_action: 'dismissed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns cached Audnexus matches without fetching Audible', async () => {
|
||||
prismaMock.audibleCache.findFirst.mockResolvedValue({
|
||||
asin: 'ASIN1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
narrator: 'Narr',
|
||||
rating: '4.5',
|
||||
description: 'Desc',
|
||||
coverArtUrl: 'cover',
|
||||
});
|
||||
|
||||
const { matchToAudnexus } = await import('@/lib/bookdate/helpers');
|
||||
const result = await matchToAudnexus('Title', 'Author');
|
||||
|
||||
expect(result?.asin).toBe('ASIN1');
|
||||
expect(result?.rating).toBe(4.5);
|
||||
expect(audibleState.ctor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when Audible search finds no results', async () => {
|
||||
prismaMock.audibleCache.findFirst.mockResolvedValue(null);
|
||||
audibleState.instance.search.mockResolvedValue({ results: [] });
|
||||
|
||||
const { matchToAudnexus } = await import('@/lib/bookdate/helpers');
|
||||
const result = await matchToAudnexus('Missing', 'Author');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(audibleState.instance.search).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when Audible details are unavailable', async () => {
|
||||
prismaMock.audibleCache.findFirst.mockResolvedValue(null);
|
||||
audibleState.instance.search.mockResolvedValue({
|
||||
results: [{ asin: 'ASIN2', title: 'Title', author: 'Author' }],
|
||||
});
|
||||
audibleState.instance.getAudiobookDetails.mockResolvedValue(null);
|
||||
|
||||
const { matchToAudnexus } = await import('@/lib/bookdate/helpers');
|
||||
const result = await matchToAudnexus('Title', 'Author');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns Audnexus details for successful Audible matches', async () => {
|
||||
prismaMock.audibleCache.findFirst.mockResolvedValue(null);
|
||||
audibleState.instance.search.mockResolvedValue({
|
||||
results: [{ asin: 'ASIN3', title: 'Title', author: 'Author' }],
|
||||
});
|
||||
audibleState.instance.getAudiobookDetails.mockResolvedValue({
|
||||
asin: 'ASIN3',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
narrator: 'Narr',
|
||||
rating: 4.2,
|
||||
description: 'Desc',
|
||||
coverArtUrl: 'cover',
|
||||
});
|
||||
|
||||
const { matchToAudnexus } = await import('@/lib/bookdate/helpers');
|
||||
const result = await matchToAudnexus('Title', 'Author');
|
||||
|
||||
expect(result).toEqual({
|
||||
asin: 'ASIN3',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
narrator: 'Narr',
|
||||
rating: 4.2,
|
||||
description: 'Desc',
|
||||
coverUrl: 'cover',
|
||||
});
|
||||
});
|
||||
|
||||
it('checks library matches using the Plex matcher', async () => {
|
||||
const { isInLibrary } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
findPlexMatchMock.mockResolvedValueOnce({ title: 'Match' });
|
||||
await expect(isInLibrary('user-1', 'Title', 'Author')).resolves.toBe(true);
|
||||
|
||||
findPlexMatchMock.mockResolvedValueOnce(null);
|
||||
await expect(isInLibrary('user-1', 'Title', 'Author')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('checks existing requests and swipes', async () => {
|
||||
const { isAlreadyRequested, isAlreadySwiped } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-1' });
|
||||
prismaMock.request.findFirst.mockResolvedValueOnce(null);
|
||||
prismaMock.bookDateSwipe.findFirst.mockResolvedValueOnce({ id: 'swipe-1' });
|
||||
prismaMock.bookDateSwipe.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(isAlreadyRequested('user-1', 'ASIN1')).resolves.toBe(true);
|
||||
await expect(isAlreadyRequested('user-1', 'ASIN1')).resolves.toBe(false);
|
||||
await expect(isAlreadySwiped('user-1', 'Title', 'Author')).resolves.toBe(true);
|
||||
await expect(isAlreadySwiped('user-1', 'Title', 'Author')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('throws on invalid AI provider', async () => {
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
await expect(callAI('invalid', 'model', 'key', '{}')).rejects.toThrow('Invalid provider');
|
||||
});
|
||||
|
||||
it('requires a base URL for custom providers', async () => {
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
|
||||
await expect(callAI('custom', 'model', 'key', '{}', null)).rejects.toThrow('Base URL is required');
|
||||
});
|
||||
|
||||
it('calls OpenAI and parses JSON recommendations', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
choices: [{ message: { content: '{\"recommendations\":[]}' } }],
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
encryptionMock.decrypt.mockReturnValue('api-key');
|
||||
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
const result = await callAI('openai', 'model', 'enc-key', '{}');
|
||||
|
||||
expect(result.recommendations).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.openai.com/v1/chat/completions',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('calls Claude and strips markdown from JSON', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
content: [{ text: '```json\n{\"recommendations\":[]}\n```' }],
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue('ok'),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
encryptionMock.decrypt.mockReturnValue('api-key');
|
||||
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
const result = await callAI('claude', 'model', 'enc-key', '{}');
|
||||
|
||||
expect(result.recommendations).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('calls custom provider and parses direct JSON', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
choices: [{ message: { content: '{\"recommendations\":[]}' } }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
encryptionMock.decrypt.mockReturnValue('api-key');
|
||||
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
const result = await callAI('custom', 'model', 'enc-key', '{}', 'http://custom/');
|
||||
|
||||
expect(result.recommendations).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://custom/chat/completions',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('retries custom providers without structured output', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: vi.fn().mockResolvedValue('response_format unsupported'),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
choices: [{ message: { content: '{\"recommendations\":[]}' } }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
encryptionMock.decrypt.mockImplementation(() => {
|
||||
throw new Error('decrypt failed');
|
||||
});
|
||||
|
||||
const { callAI } = await import('@/lib/bookdate/helpers');
|
||||
const result = await callAI('custom', 'model', 'enc-key', '{}', 'http://custom');
|
||||
|
||||
expect(result.recommendations).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Component: Admin Settings - Indexers Tab Auto-load Test
|
||||
* Documentation: documentation/testing.md
|
||||
*
|
||||
* This test verifies that indexers are automatically loaded when:
|
||||
* 1. The prowlarr tab becomes active
|
||||
* 2. Prowlarr URL and API key are configured
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { IndexersTab } from '@/app/admin/settings/tabs/IndexersTab';
|
||||
import type { Settings, SavedIndexerConfig } from '@/app/admin/settings/lib/types';
|
||||
import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm';
|
||||
|
||||
// Mock fetchWithAuth
|
||||
const mockFetchWithAuth = vi.fn();
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: (url: string, options?: any) => mockFetchWithAuth(url, options),
|
||||
}));
|
||||
|
||||
// Mock child components to simplify testing
|
||||
vi.mock('@/components/admin/indexers/IndexerManagement', () => ({
|
||||
IndexerManagement: ({ initialIndexers }: { initialIndexers: SavedIndexerConfig[] }) => (
|
||||
<div data-testid="indexer-management">
|
||||
{initialIndexers.length > 0 ? (
|
||||
<div data-testid="indexers-loaded">
|
||||
{initialIndexers.length} indexers loaded
|
||||
</div>
|
||||
) : (
|
||||
<div data-testid="indexers-empty">No indexers</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/admin/FlagConfigRow', () => ({
|
||||
FlagConfigRow: () => <div data-testid="flag-config-row">Flag Config</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Button', () => ({
|
||||
Button: ({ children, onClick, loading, disabled, ...props }: any) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading || disabled}
|
||||
data-testid={props['data-testid'] || 'button'}
|
||||
>
|
||||
{loading ? 'Loading...' : children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Input', () => ({
|
||||
Input: (props: any) => <input {...props} data-testid={props['data-testid'] || 'input'} />,
|
||||
}));
|
||||
|
||||
describe('IndexersTab - Auto-load Indexers on Tab Activation', () => {
|
||||
const mockSettings: Settings = {
|
||||
backendMode: 'plex',
|
||||
hasLocalUsers: false,
|
||||
audibleRegion: 'us',
|
||||
plex: {
|
||||
url: 'http://plex.local:32400',
|
||||
token: 'test-token',
|
||||
libraryId: '1',
|
||||
triggerScanAfterImport: false,
|
||||
},
|
||||
audiobookshelf: {
|
||||
serverUrl: '',
|
||||
apiToken: '',
|
||||
libraryId: '',
|
||||
triggerScanAfterImport: false,
|
||||
},
|
||||
oidc: {
|
||||
enabled: false,
|
||||
providerName: '',
|
||||
issuerUrl: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
accessControlMethod: 'open',
|
||||
accessGroupClaim: '',
|
||||
accessGroupValue: '',
|
||||
allowedEmails: '',
|
||||
allowedUsernames: '',
|
||||
adminClaimEnabled: false,
|
||||
adminClaimName: '',
|
||||
adminClaimValue: '',
|
||||
},
|
||||
registration: {
|
||||
enabled: false,
|
||||
requireAdminApproval: false,
|
||||
},
|
||||
prowlarr: {
|
||||
url: 'http://prowlarr.local:9696',
|
||||
apiKey: 'test-api-key',
|
||||
},
|
||||
downloadClient: {
|
||||
type: 'qbittorrent',
|
||||
url: 'http://localhost:8080',
|
||||
username: 'admin',
|
||||
password: 'password',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
remotePath: '',
|
||||
localPath: '',
|
||||
},
|
||||
paths: {
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: true,
|
||||
},
|
||||
ebook: {
|
||||
enabled: false,
|
||||
preferredFormat: 'epub',
|
||||
baseUrl: 'https://annas-archive.li',
|
||||
flaresolverrUrl: '',
|
||||
},
|
||||
};
|
||||
|
||||
const mockIndexers: SavedIndexerConfig[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'AudioBook Bay',
|
||||
priority: 10,
|
||||
seedingTimeMinutes: 4320,
|
||||
rssEnabled: true,
|
||||
categories: [3030],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'MyAnonaMouse',
|
||||
priority: 15,
|
||||
seedingTimeMinutes: 10080,
|
||||
rssEnabled: false,
|
||||
categories: [3030],
|
||||
},
|
||||
];
|
||||
|
||||
const mockFlagConfigs: IndexerFlagConfig[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should display empty indexers when no indexers are loaded', () => {
|
||||
const { container } = render(
|
||||
<IndexersTab
|
||||
settings={mockSettings}
|
||||
indexers={[]}
|
||||
flagConfigs={mockFlagConfigs}
|
||||
onChange={vi.fn()}
|
||||
onIndexersChange={vi.fn()}
|
||||
onFlagConfigsChange={vi.fn()}
|
||||
onValidationChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('indexers-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display indexers when indexers prop contains data', () => {
|
||||
render(
|
||||
<IndexersTab
|
||||
settings={mockSettings}
|
||||
indexers={mockIndexers}
|
||||
flagConfigs={mockFlagConfigs}
|
||||
onChange={vi.fn()}
|
||||
onIndexersChange={vi.fn()}
|
||||
onFlagConfigsChange={vi.fn()}
|
||||
onValidationChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('indexers-loaded')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 indexers loaded')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('BUG: should automatically fetch indexers when onRefreshIndexers is called on mount', async () => {
|
||||
const mockOnRefreshIndexers = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<IndexersTab
|
||||
settings={mockSettings}
|
||||
indexers={[]} // Start with empty
|
||||
flagConfigs={mockFlagConfigs}
|
||||
onChange={vi.fn()}
|
||||
onIndexersChange={vi.fn()}
|
||||
onFlagConfigsChange={vi.fn()}
|
||||
onValidationChange={vi.fn()}
|
||||
onRefreshIndexers={mockOnRefreshIndexers}
|
||||
/>
|
||||
);
|
||||
|
||||
// The bug: onRefreshIndexers should be called automatically when the component mounts
|
||||
// IF prowlarr URL and API key are configured
|
||||
await waitFor(() => {
|
||||
expect(mockOnRefreshIndexers).toHaveBeenCalledTimes(1);
|
||||
}, { timeout: 1000 });
|
||||
});
|
||||
|
||||
it('should NOT auto-fetch indexers if prowlarr URL is missing', async () => {
|
||||
const mockOnRefreshIndexers = vi.fn().mockResolvedValue(undefined);
|
||||
const settingsWithoutUrl = {
|
||||
...mockSettings,
|
||||
prowlarr: { url: '', apiKey: 'test-api-key' },
|
||||
};
|
||||
|
||||
render(
|
||||
<IndexersTab
|
||||
settings={settingsWithoutUrl}
|
||||
indexers={[]}
|
||||
flagConfigs={mockFlagConfigs}
|
||||
onChange={vi.fn()}
|
||||
onIndexersChange={vi.fn()}
|
||||
onFlagConfigsChange={vi.fn()}
|
||||
onValidationChange={vi.fn()}
|
||||
onRefreshIndexers={mockOnRefreshIndexers}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should NOT call onRefreshIndexers because URL is missing
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(mockOnRefreshIndexers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT auto-fetch indexers if prowlarr API key is missing', async () => {
|
||||
const mockOnRefreshIndexers = vi.fn().mockResolvedValue(undefined);
|
||||
const settingsWithoutApiKey = {
|
||||
...mockSettings,
|
||||
prowlarr: { url: 'http://prowlarr.local:9696', apiKey: '' },
|
||||
};
|
||||
|
||||
render(
|
||||
<IndexersTab
|
||||
settings={settingsWithoutApiKey}
|
||||
indexers={[]}
|
||||
flagConfigs={mockFlagConfigs}
|
||||
onChange={vi.fn()}
|
||||
onIndexersChange={vi.fn()}
|
||||
onFlagConfigsChange={vi.fn()}
|
||||
onValidationChange={vi.fn()}
|
||||
onRefreshIndexers={mockOnRefreshIndexers}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should NOT call onRefreshIndexers because API key is missing
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(mockOnRefreshIndexers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Component: Job Queue Mock Factory
|
||||
* Documentation: documentation/backend/services/jobs.md
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const createJobQueueMock = () => ({
|
||||
addSearchJob: vi.fn(),
|
||||
addDownloadJob: vi.fn(),
|
||||
addMonitorJob: vi.fn(),
|
||||
addOrganizeJob: vi.fn(),
|
||||
addPlexScanJob: vi.fn(),
|
||||
addPlexMatchJob: vi.fn(),
|
||||
addPlexRecentlyAddedJob: vi.fn(),
|
||||
addMonitorRssFeedsJob: vi.fn(),
|
||||
addAudibleRefreshJob: vi.fn(),
|
||||
addRetryMissingTorrentsJob: vi.fn(),
|
||||
addRetryFailedImportsJob: vi.fn(),
|
||||
addCleanupSeededTorrentsJob: vi.fn(),
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Component: Prisma Mock Factory
|
||||
* Documentation: documentation/backend/database.md
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
type PrismaModelMock = {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const createModelMock = (): PrismaModelMock => ({
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(() => Promise.resolve({})),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
});
|
||||
|
||||
export const createPrismaMock = () => ({
|
||||
configuration: createModelMock(),
|
||||
user: createModelMock(),
|
||||
request: createModelMock(),
|
||||
audiobook: createModelMock(),
|
||||
downloadHistory: createModelMock(),
|
||||
plexLibrary: createModelMock(),
|
||||
audibleCache: createModelMock(),
|
||||
job: createModelMock(),
|
||||
jobEvent: createModelMock(),
|
||||
scheduledJob: createModelMock(),
|
||||
bookDateConfig: createModelMock(),
|
||||
bookDateRecommendation: createModelMock(),
|
||||
bookDateSwipe: createModelMock(),
|
||||
$queryRaw: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
});
|
||||
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* Component: Audible Integration Service Tests
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AudibleService } from '@/lib/integrations/audible.service';
|
||||
import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '@/lib/types/audible';
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => ({
|
||||
create: vi.fn(() => clientMock),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getAudibleRegion: vi.fn(),
|
||||
}));
|
||||
|
||||
const fsCoreMock = vi.hoisted(() => ({
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => fsCoreMock);
|
||||
|
||||
describe('AudibleService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clientMock.get.mockReset();
|
||||
axiosMock.get.mockReset();
|
||||
configServiceMock.getAudibleRegion.mockReset();
|
||||
});
|
||||
|
||||
const buildListHtml = (count: number, startIndex: number = 0) =>
|
||||
Array.from({ length: count }, (_, i) => {
|
||||
const asin = `B${String(i + 1 + startIndex).padStart(9, '0')}`;
|
||||
return `
|
||||
<div class="productListItem">
|
||||
<li data-asin="${asin}"></li>
|
||||
<h3><a>Title ${i + 1}</a></h3>
|
||||
<span class="authorLabel">By: Author ${i + 1}</span>
|
||||
<span class="narratorLabel">Narrated by: Narrator ${i + 1}</span>
|
||||
<img src="https://images-na.ssl-images-amazon.com/images/I/abc._SL200_.jpg" />
|
||||
<span class="ratingsLabel">4.${i} out of 5 stars</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
it('parses search results from HTML', async () => {
|
||||
const html = `
|
||||
<div class="s-result-item">
|
||||
<li data-asin="B000123456"></li>
|
||||
<h2>The Test Book</h2>
|
||||
<a href="/author/Author-Name">Author Name</a>
|
||||
<span class="narratorLabel">Narrated by: Narrator Name</span>
|
||||
<img src="https://images-na.ssl-images-amazon.com/images/I/abc._SL200_.jpg" />
|
||||
<span class="runtimeLabel">Length: 5 hrs and 30 mins</span>
|
||||
<span class="ratingsLabel">4.5 out of 5 stars</span>
|
||||
</div>
|
||||
<div class="resultsInfo">1-20 of 55 results</div>
|
||||
`;
|
||||
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const result = await service.search('test', 1);
|
||||
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0].asin).toBe('B000123456');
|
||||
expect(result.results[0].title).toBe('The Test Book');
|
||||
expect(result.results[0].author).toBe('Author Name');
|
||||
expect(result.results[0].narrator).toBe('Narrator Name');
|
||||
expect(result.results[0].durationMinutes).toBe(330);
|
||||
expect(result.results[0].rating).toBe(4.5);
|
||||
expect(result.results[0].coverArtUrl).toContain('_SL500_');
|
||||
expect(result.totalResults).toBe(55);
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('reinitializes when the configured region changes', async () => {
|
||||
const html = `<div class="resultsInfo">0 results</div>`;
|
||||
configServiceMock.getAudibleRegion
|
||||
.mockResolvedValueOnce('us')
|
||||
.mockResolvedValueOnce('uk')
|
||||
.mockResolvedValueOnce('uk');
|
||||
clientMock.get.mockResolvedValue({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
await service.search('test', 1);
|
||||
await service.search('test', 1);
|
||||
|
||||
expect(axiosMock.create).toHaveBeenCalledTimes(2);
|
||||
expect(axiosMock.create.mock.calls[1][0].baseURL).toBe(AUDIBLE_REGIONS.uk.baseUrl);
|
||||
});
|
||||
|
||||
it('reinitializes when forced manually', async () => {
|
||||
const html = `<div class="resultsInfo">0 results</div>`;
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockResolvedValue({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
await service.search('test', 1);
|
||||
service.forceReinitialize();
|
||||
await service.search('test', 1);
|
||||
|
||||
expect(axiosMock.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('falls back to default region when initialization fails', async () => {
|
||||
const html = `<div class="resultsInfo">0 results</div>`;
|
||||
configServiceMock.getAudibleRegion.mockRejectedValue(new Error('config fail'));
|
||||
clientMock.get.mockResolvedValue({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const result = await service.search('fallback', 1);
|
||||
|
||||
expect(result.totalResults).toBe(0);
|
||||
expect(axiosMock.create.mock.calls[0][0].baseURL).toBe(AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION].baseUrl);
|
||||
});
|
||||
|
||||
it('paginates new releases and respects delays between pages', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({ data: buildListHtml(10, 0) })
|
||||
.mockResolvedValueOnce({ data: buildListHtml(5, 10) });
|
||||
|
||||
const service = new AudibleService();
|
||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||
const results = await service.getNewReleases(25);
|
||||
|
||||
expect(results).toHaveLength(15);
|
||||
expect(delaySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('parses popular audiobooks and stops early when fewer results are found', async () => {
|
||||
const html = `
|
||||
<div class="productListItem">
|
||||
<li data-asin="B000111111"></li>
|
||||
<h3><a>Popular One</a></h3>
|
||||
<span class="authorLabel">By: Author One</span>
|
||||
<span class="narratorLabel">Narrated by: Narrator One</span>
|
||||
<img src="https://images-na.ssl-images-amazon.com/images/I/abc._SL200_.jpg" />
|
||||
<span class="ratingsLabel">4.2 out of 5 stars</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const results = await service.getPopularAudiobooks(1);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].asin).toBe('B000111111');
|
||||
expect(results[0].title).toBe('Popular One');
|
||||
});
|
||||
|
||||
it('skips duplicate ASINs when parsing new releases', async () => {
|
||||
const html = `
|
||||
<div class="productListItem">
|
||||
<li data-asin="B000222222"></li>
|
||||
<h3><a>Title One</a></h3>
|
||||
</div>
|
||||
<div class="productListItem">
|
||||
<li data-asin="B000222222"></li>
|
||||
<h3><a>Title Two</a></h3>
|
||||
</div>
|
||||
`;
|
||||
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const results = await service.getNewReleases(20);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe('Title One');
|
||||
});
|
||||
|
||||
it('returns empty search results on failures', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockRejectedValue(new Error('nope'));
|
||||
|
||||
const service = new AudibleService();
|
||||
const result = await service.search('oops', 1);
|
||||
|
||||
expect(result.results).toEqual([]);
|
||||
expect(result.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('returns audiobooks from Audnexus when available', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
title: 'Audnexus Book',
|
||||
authors: [{ name: 'Author A' }],
|
||||
narrators: [{ name: 'Narrator A' }],
|
||||
description: 'Desc',
|
||||
image: 'https://images.example.com/cover._SL200_.jpg',
|
||||
runtimeLengthMin: '300',
|
||||
genres: ['Fiction'],
|
||||
rating: '4.7',
|
||||
},
|
||||
});
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000AAAAAA');
|
||||
|
||||
expect(details?.title).toBe('Audnexus Book');
|
||||
expect(details?.author).toBe('Author A');
|
||||
expect(details?.durationMinutes).toBe(300);
|
||||
expect(details?.coverArtUrl).toContain('_SL500_');
|
||||
});
|
||||
|
||||
it('scrapes details from HTML when Audnexus fails', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 500 }, message: 'boom' });
|
||||
|
||||
const html = `
|
||||
<script type="application/ld+json">{invalid}</script>
|
||||
<div class="product-top-section">
|
||||
<h1 class="bc-heading">HTML Title</h1>
|
||||
<li class="authorLabel"><a>By: HTML Author</a></li>
|
||||
<li class="narratorLabel"><a>Narrated by: HTML Narrator</a></li>
|
||||
<li class="runtimeLabel"><span>Length: 2 hrs and 5 mins</span></li>
|
||||
<li>Release date: Jan 2, 2022</li>
|
||||
<span class="ratingsLabel">4.8 out of 5 stars</span>
|
||||
<img class="bc-image-inset-border" src="https://images.example.com/cover._SL200_.jpg" />
|
||||
<div class="bc-expander-content">
|
||||
This is a long description for testing the Audible HTML parsing logic.
|
||||
</div>
|
||||
<a href="/cat/fiction">Fiction</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000CCCCCC');
|
||||
|
||||
expect(details?.title).toBe('HTML Title');
|
||||
expect(details?.author).toBe('HTML Author');
|
||||
expect(details?.narrator).toBe('HTML Narrator');
|
||||
expect(details?.durationMinutes).toBe(125);
|
||||
expect(details?.rating).toBe(4.8);
|
||||
expect(details?.releaseDate).toBe('Jan 2, 2022');
|
||||
expect(details?.coverArtUrl).toContain('_SL500_');
|
||||
expect(details?.genres).toContain('Fiction');
|
||||
});
|
||||
|
||||
it('falls back to Audible scraping when Audnexus returns 404', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
|
||||
|
||||
const html = `
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@type": "Product",
|
||||
"name": "Fallback Book",
|
||||
"author": {"name": "Fallback Author"},
|
||||
"readBy": {"name": "Fallback Narrator"},
|
||||
"description": "A long description that exceeds fifty characters for validation.",
|
||||
"image": "https://images.example.com/cover._SL200_.jpg",
|
||||
"aggregateRating": { "ratingValue": "4.6" },
|
||||
"datePublished": "Jan 1, 2024",
|
||||
"duration": "PT8H30M"
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000BBBBBB');
|
||||
|
||||
expect(details?.title).toBe('Fallback Book');
|
||||
expect(details?.author).toBe('Fallback Author');
|
||||
expect(details?.durationMinutes).toBe(510);
|
||||
});
|
||||
|
||||
it('returns runtime from Audnexus data', async () => {
|
||||
axiosMock.get.mockResolvedValue({ data: { runtimeLengthMin: '480' } });
|
||||
|
||||
const service = new AudibleService();
|
||||
const runtime = await service.getRuntime('B000123456');
|
||||
|
||||
expect(runtime).toBe(480);
|
||||
});
|
||||
|
||||
it('returns null runtime when Audnexus returns 404', async () => {
|
||||
axiosMock.get.mockRejectedValue({ response: { status: 404 }, message: 'Not found' });
|
||||
|
||||
const service = new AudibleService();
|
||||
const runtime = await service.getRuntime('B000404404');
|
||||
|
||||
expect(runtime).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null runtime when Audnexus errors unexpectedly', async () => {
|
||||
axiosMock.get.mockRejectedValue({ response: { status: 500 }, message: 'Boom' });
|
||||
|
||||
const service = new AudibleService();
|
||||
const runtime = await service.getRuntime('B000500500');
|
||||
|
||||
expect(runtime).toBeNull();
|
||||
});
|
||||
|
||||
it('parses runtime strings into minutes', () => {
|
||||
const service = new AudibleService();
|
||||
const parseRuntime = (service as any).parseRuntime.bind(service);
|
||||
|
||||
expect(parseRuntime('Length: 1 hr and 5 mins')).toBe(65);
|
||||
expect(parseRuntime('Length: 45 mins')).toBe(45);
|
||||
expect(parseRuntime('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not reinitialize when the region is unchanged', async () => {
|
||||
const html = `<div class="resultsInfo">0 results</div>`;
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockResolvedValue({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
await service.search('test', 1);
|
||||
await service.search('test', 1);
|
||||
|
||||
expect(axiosMock.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('paginates popular audiobooks across pages', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({ data: buildListHtml(10, 0) })
|
||||
.mockResolvedValueOnce({ data: buildListHtml(10, 10) });
|
||||
|
||||
const service = new AudibleService();
|
||||
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
|
||||
const results = await service.getPopularAudiobooks(25);
|
||||
|
||||
expect(results).toHaveLength(20);
|
||||
expect(delaySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns empty popular audiobooks on errors', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const service = new AudibleService();
|
||||
const results = await service.getPopularAudiobooks(5);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty new releases on errors', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
clientMock.get.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const service = new AudibleService();
|
||||
const results = await service.getNewReleases(5);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null when getAudiobookDetails throws', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
|
||||
const service = new AudibleService();
|
||||
vi.spyOn(service as any, 'fetchFromAudnexus').mockResolvedValue(null);
|
||||
vi.spyOn(service as any, 'scrapeAudibleDetails').mockRejectedValue(new Error('boom'));
|
||||
|
||||
const result = await service.getAudiobookDetails('B000TEST');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('writes debug HTML in development mode', async () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
|
||||
clientMock.get.mockResolvedValueOnce({
|
||||
data: '<div class="product-top-section"><h1 class="bc-heading">Dev Book</h1></div>',
|
||||
});
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000DEV');
|
||||
|
||||
expect(details?.title).toBe('Dev Book');
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
it('parses JSON-LD author and narrator arrays', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
|
||||
|
||||
const html = `
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@type": "Product",
|
||||
"name": "Array Book",
|
||||
"author": [{"name": "Author One"}, {"name": "Author Two"}],
|
||||
"readBy": [{"name": "Narrator One"}, {"name": "Narrator Two"}],
|
||||
"description": "A description that is long enough to be accepted in tests.",
|
||||
"image": "https://images.example.com/cover._SL200_.jpg",
|
||||
"duration": "PT1H30M"
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000ARRAY');
|
||||
|
||||
expect(details?.author).toBe('Author One, Author Two');
|
||||
expect(details?.narrator).toBe('Narrator One, Narrator Two');
|
||||
});
|
||||
|
||||
it('falls back to author and narrator links when labels are missing', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
|
||||
|
||||
const html = `
|
||||
<div class="product-top-section">
|
||||
<a href="/author/Author-One">Author One</a>
|
||||
<a href="/author/See-All">See all</a>
|
||||
<a href="/narrator/Narr-One">Narrator One</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000LINKS');
|
||||
|
||||
expect(details?.author).toBe('Author One');
|
||||
expect(details?.narrator).toBe('Narrator One');
|
||||
});
|
||||
|
||||
it('extracts descriptions from fallback paragraphs', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
|
||||
|
||||
const html = `
|
||||
<p>This description is intentionally long enough to satisfy the minimum length requirement for parsing.</p>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000DESC');
|
||||
|
||||
expect(details?.description).toContain('intentionally long enough');
|
||||
});
|
||||
|
||||
it('detects runtime from generic duration text', async () => {
|
||||
configServiceMock.getAudibleRegion.mockResolvedValue('us');
|
||||
axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' });
|
||||
|
||||
const html = `
|
||||
<span>10 hr 2 min</span>
|
||||
`;
|
||||
|
||||
clientMock.get.mockResolvedValueOnce({ data: html });
|
||||
|
||||
const service = new AudibleService();
|
||||
const details = await service.getAudiobookDetails('B000TIME');
|
||||
|
||||
expect(details?.durationMinutes).toBe(602);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,709 @@
|
||||
/**
|
||||
* Component: Plex Integration Service Tests
|
||||
* Documentation: documentation/integrations/plex.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PlexService } from '@/lib/integrations/plex.service';
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => ({
|
||||
create: vi.fn(() => clientMock),
|
||||
}));
|
||||
|
||||
const parseStringPromiseMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('xml2js', () => ({
|
||||
parseStringPromise: parseStringPromiseMock,
|
||||
}));
|
||||
|
||||
describe('PlexService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('requests a PIN for OAuth', async () => {
|
||||
clientMock.post.mockResolvedValue({ data: { id: 123, code: 'CODE' } });
|
||||
|
||||
const service = new PlexService();
|
||||
const pin = await service.requestPin();
|
||||
|
||||
expect(pin).toEqual({ id: 123, code: 'CODE' });
|
||||
});
|
||||
|
||||
it('throws when PIN request fails', async () => {
|
||||
clientMock.post.mockRejectedValue(new Error('fail'));
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.requestPin()).rejects.toThrow('Failed to request authentication PIN from Plex');
|
||||
});
|
||||
|
||||
it('returns null when PIN check fails', async () => {
|
||||
clientMock.get.mockRejectedValue(new Error('fail'));
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.checkPin(123);
|
||||
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('returns auth token when PIN is authorized', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: { authToken: 'plex-token' } });
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.checkPin(456);
|
||||
|
||||
expect(token).toBe('plex-token');
|
||||
});
|
||||
|
||||
it('parses user info from XML responses', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
user: {
|
||||
$: { id: '1', username: 'user', email: 'e@example.com', thumb: '/t' },
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const user = await service.getUserInfo('token');
|
||||
|
||||
expect(user).toEqual({
|
||||
id: 1,
|
||||
username: 'user',
|
||||
email: 'e@example.com',
|
||||
thumb: '/t',
|
||||
authToken: 'token',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses user info from JSON responses and falls back to title', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: { id: '2', title: 'TitleUser', email: 't@example.com', thumb: '/t' },
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const user = await service.getUserInfo('token');
|
||||
|
||||
expect(user.username).toBe('TitleUser');
|
||||
expect(user.id).toBe(2);
|
||||
});
|
||||
|
||||
it('throws for unexpected XML user structure', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({ notUser: {} });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.getUserInfo('token')).rejects.toThrow('Unexpected XML structure');
|
||||
});
|
||||
|
||||
it('throws for unexpected response formats', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: 42 });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.getUserInfo('token')).rejects.toThrow('Unexpected response format from Plex');
|
||||
});
|
||||
|
||||
it('throws when user info is missing required fields', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: { username: 'user' } });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.getUserInfo('token')).rejects.toThrow('User ID missing');
|
||||
});
|
||||
|
||||
it('throws when username is missing from user info', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: { id: '3' } });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.getUserInfo('token')).rejects.toThrow('Username missing');
|
||||
});
|
||||
|
||||
it('returns OAuth URLs with pinId', () => {
|
||||
const service = new PlexService();
|
||||
const url = service.getOAuthUrl('CODE', 42, 'http://app/callback');
|
||||
|
||||
expect(url).toContain('CODE');
|
||||
expect(url).toContain('pinId%3D42');
|
||||
});
|
||||
|
||||
it('tests connections and parses MediaContainer responses', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
MediaContainer: {
|
||||
machineIdentifier: 'machine',
|
||||
version: '1.0.0',
|
||||
platform: 'Plex',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const result = await service.testConnection('http://plex', 'token');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.info?.machineIdentifier).toBe('machine');
|
||||
});
|
||||
|
||||
it('tests connections from XML identity responses', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
MediaContainer: { $: { machineIdentifier: 'm1', version: '1.2.3', platform: 'Linux', platformVersion: '5' } },
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const result = await service.testConnection('http://plex', 'token');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.info?.platform).toBe('Linux');
|
||||
});
|
||||
|
||||
it('finds server access tokens in plex resources', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{ clientIdentifier: 'machine', accessToken: 'server-token' },
|
||||
],
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.getServerAccessToken('machine', 'user-token');
|
||||
|
||||
expect(token).toBe('server-token');
|
||||
});
|
||||
|
||||
it('returns null when server resource is missing', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: [{ clientIdentifier: 'other', accessToken: 'x' }] });
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.getServerAccessToken('machine', 'user-token');
|
||||
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when server access token is missing', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [{ clientIdentifier: 'machine', accessToken: null }],
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.getServerAccessToken('machine', 'user-token');
|
||||
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('verifies server access for matching resources', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{ clientIdentifier: 'machine', provides: 'server', name: 'Plex' },
|
||||
],
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const hasAccess = await service.verifyServerAccess('http://plex', 'machine', 'user-token');
|
||||
|
||||
expect(hasAccess).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when server access is not available', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [{ clientIdentifier: 'other', provides: 'client', name: 'Plex' }],
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const hasAccess = await service.verifyServerAccess('http://plex', 'machine', 'user-token');
|
||||
|
||||
expect(hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when verifying server access errors', async () => {
|
||||
clientMock.get.mockRejectedValue({ response: { status: 500, data: 'oops' }, message: 'boom' });
|
||||
|
||||
const service = new PlexService();
|
||||
const hasAccess = await service.verifyServerAccess('http://plex', 'machine', 'user-token');
|
||||
|
||||
expect(hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
it('parses libraries from XML responses', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
MediaContainer: {
|
||||
Directory: [
|
||||
{
|
||||
$: {
|
||||
key: '1',
|
||||
title: 'Books',
|
||||
type: 'artist',
|
||||
language: 'en',
|
||||
scanner: 'scanner',
|
||||
agent: 'agent',
|
||||
},
|
||||
Location: [{ $: { path: '/data' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const libs = await service.getLibraries('http://plex', 'token');
|
||||
|
||||
expect(libs).toEqual([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Books',
|
||||
type: 'artist',
|
||||
language: 'en',
|
||||
scanner: 'scanner',
|
||||
agent: 'agent',
|
||||
locations: ['/data'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses libraries from JSON responses', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
MediaContainer: {
|
||||
Directory: [
|
||||
{
|
||||
key: '2',
|
||||
title: 'Library',
|
||||
type: 'artist',
|
||||
language: 'en',
|
||||
scanner: 'scanner',
|
||||
agent: 'agent',
|
||||
Location: [{ path: '/media' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const libs = await service.getLibraries('http://plex', 'token');
|
||||
|
||||
expect(libs).toEqual([
|
||||
{
|
||||
id: '2',
|
||||
title: 'Library',
|
||||
type: 'artist',
|
||||
language: 'en',
|
||||
scanner: 'scanner',
|
||||
agent: 'agent',
|
||||
locations: ['/media'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null metadata for unauthorized users', async () => {
|
||||
clientMock.get.mockRejectedValue({ response: { status: 401 } });
|
||||
|
||||
const service = new PlexService();
|
||||
const meta = await service.getItemMetadata('http://plex', 'token', 'rk-1');
|
||||
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null metadata when item is missing', async () => {
|
||||
clientMock.get.mockRejectedValue({ response: { status: 404 } });
|
||||
|
||||
const service = new PlexService();
|
||||
const meta = await service.getItemMetadata('http://plex', 'token', 'rk-2');
|
||||
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
|
||||
it('parses metadata from XML responses', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
MediaContainer: {
|
||||
Metadata: [{ $: { userRating: '9' } }],
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const meta = await service.getItemMetadata('http://plex', 'token', 'rk-3');
|
||||
|
||||
expect(meta?.userRating).toBe(9);
|
||||
});
|
||||
|
||||
it('returns user ratings when metadata exists', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: { MediaContainer: { Metadata: [{ userRating: '7.5' }] } },
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const meta = await service.getItemMetadata('http://plex', 'token', 'rk-1');
|
||||
|
||||
expect(meta?.userRating).toBe(7.5);
|
||||
});
|
||||
|
||||
it('searches library content from XML responses', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
MediaContainer: {
|
||||
Metadata: [
|
||||
{
|
||||
$: {
|
||||
ratingKey: 'rk-1',
|
||||
guid: 'guid-1',
|
||||
title: 'Title',
|
||||
grandparentTitle: 'Author',
|
||||
summary: 'Summary',
|
||||
thumb: '/thumb',
|
||||
addedAt: '1',
|
||||
updatedAt: '2',
|
||||
duration: '1000',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const results = await service.searchLibrary('http://plex', 'token', 'lib-1', 'Title');
|
||||
|
||||
expect(results[0].ratingKey).toBe('rk-1');
|
||||
expect(results[0].author).toBe('Author');
|
||||
});
|
||||
|
||||
it('returns empty arrays when search fails', async () => {
|
||||
clientMock.get.mockRejectedValue(new Error('search fail'));
|
||||
|
||||
const service = new PlexService();
|
||||
const results = await service.searchLibrary('http://plex', 'token', 'lib-1', 'Title');
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty arrays when recently added data is not a list', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: { MediaContainer: { Metadata: {} } },
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const results = await service.getRecentlyAdded('http://plex', 'token', 'lib-1', 10);
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty arrays when library content data is not a list', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: { MediaContainer: { Metadata: {} } },
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const results = await service.getLibraryContent('http://plex', 'token', 'lib-1');
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses library content from XML responses', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
MediaContainer: {
|
||||
Metadata: [
|
||||
{
|
||||
ratingKey: 'rk-1',
|
||||
guid: 'guid-1',
|
||||
title: 'Title',
|
||||
parentTitle: 'Author',
|
||||
writer: 'Narr',
|
||||
duration: '1000',
|
||||
year: '2020',
|
||||
summary: 'Summary',
|
||||
thumb: '/thumb',
|
||||
addedAt: '1',
|
||||
updatedAt: '2',
|
||||
userRating: '7',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const results = await service.getLibraryContent('http://plex', 'token', 'lib-1');
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
ratingKey: 'rk-1',
|
||||
guid: 'guid-1',
|
||||
title: 'Title',
|
||||
author: 'Author',
|
||||
narrator: 'Narr',
|
||||
duration: 1000,
|
||||
year: 2020,
|
||||
summary: 'Summary',
|
||||
thumb: '/thumb',
|
||||
addedAt: 1,
|
||||
updatedAt: 2,
|
||||
userRating: 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when fetching library content fails with 401', async () => {
|
||||
clientMock.get.mockRejectedValue({ response: { status: 401 } });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.getLibraryContent('http://plex', 'token', 'lib-1')).rejects.toThrow(
|
||||
'Failed to retrieve content from Plex library'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns recently added items from JSON responses', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
MediaContainer: {
|
||||
Metadata: [
|
||||
{
|
||||
ratingKey: 'rk-2',
|
||||
guid: 'guid-2',
|
||||
title: 'New Title',
|
||||
parentTitle: 'Author',
|
||||
writer: 'Narrator',
|
||||
duration: '2000',
|
||||
year: '2021',
|
||||
summary: 'Summary',
|
||||
thumb: '/thumb2',
|
||||
addedAt: '3',
|
||||
updatedAt: '4',
|
||||
userRating: '8',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const results = await service.getRecentlyAdded('http://plex', 'token', 'lib-1', 5);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
ratingKey: 'rk-2',
|
||||
guid: 'guid-2',
|
||||
title: 'New Title',
|
||||
author: 'Author',
|
||||
narrator: 'Narrator',
|
||||
duration: 2000,
|
||||
year: 2021,
|
||||
summary: 'Summary',
|
||||
thumb: '/thumb2',
|
||||
addedAt: 3,
|
||||
updatedAt: 4,
|
||||
userRating: 8,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('triggers Plex library scans', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: {} });
|
||||
|
||||
const service = new PlexService();
|
||||
await expect(service.scanLibrary('http://plex', 'token', 'lib-1')).resolves.toBeUndefined();
|
||||
|
||||
expect(clientMock.get).toHaveBeenCalledWith(
|
||||
'http://plex/library/sections/lib-1/refresh',
|
||||
expect.objectContaining({
|
||||
headers: { 'X-Plex-Token': 'token' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when scan triggers fail', async () => {
|
||||
clientMock.get.mockRejectedValue(new Error('scan failed'));
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.scanLibrary('http://plex', 'token', 'lib-1')).rejects.toThrow(
|
||||
'Failed to trigger Plex library scan'
|
||||
);
|
||||
});
|
||||
|
||||
it('collects ratings in batch and skips failures', async () => {
|
||||
const service = new PlexService();
|
||||
const getItemSpy = vi.spyOn(service, 'getItemMetadata')
|
||||
.mockResolvedValueOnce({ userRating: 4 })
|
||||
.mockRejectedValueOnce({ response: { status: 401 } })
|
||||
.mockResolvedValueOnce({ userRating: 3 });
|
||||
|
||||
const ratings = await service.batchGetUserRatings('http://plex', 'token', ['a', 'b', 'c']);
|
||||
|
||||
expect(getItemSpy).toHaveBeenCalledTimes(3);
|
||||
expect(ratings.get('a')).toBe(4);
|
||||
expect(ratings.get('c')).toBe(3);
|
||||
expect(ratings.has('b')).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts home users from API responses', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
MediaContainer: {
|
||||
User: [
|
||||
{
|
||||
$: {
|
||||
id: '1',
|
||||
uuid: 'uuid',
|
||||
title: 'User',
|
||||
username: 'user',
|
||||
email: 'user@example.com',
|
||||
thumb: '/thumb',
|
||||
hasPassword: '1',
|
||||
restricted: '0',
|
||||
admin: '1',
|
||||
guest: '0',
|
||||
protected: '0',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const users = await service.getHomeUsers('token');
|
||||
|
||||
expect(users).toHaveLength(1);
|
||||
expect(users[0].friendlyName).toBe('User');
|
||||
expect(users[0].admin).toBe(true);
|
||||
});
|
||||
|
||||
it('extracts home users from home.users responses', async () => {
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
home: {
|
||||
users: [
|
||||
{
|
||||
user: {
|
||||
id: '2',
|
||||
uuid: 'uuid-2',
|
||||
title: 'Kid',
|
||||
username: 'kid',
|
||||
email: 'kid@example.com',
|
||||
thumb: '/thumb',
|
||||
hasPassword: 'true',
|
||||
restricted: 'true',
|
||||
admin: 'false',
|
||||
guest: 'false',
|
||||
protected: 'true',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const users = await service.getHomeUsers('token');
|
||||
|
||||
expect(users).toHaveLength(1);
|
||||
expect(users[0].friendlyName).toBe('Kid');
|
||||
expect(users[0].restricted).toBe(true);
|
||||
expect(users[0].protected).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty list when no home users are available', async () => {
|
||||
clientMock.get.mockResolvedValue({ data: {} });
|
||||
|
||||
const service = new PlexService();
|
||||
const users = await service.getHomeUsers('token');
|
||||
|
||||
expect(users).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty list when fetching home users fails', async () => {
|
||||
clientMock.get.mockRejectedValue(new Error('home users down'));
|
||||
|
||||
const service = new PlexService();
|
||||
const users = await service.getHomeUsers('token');
|
||||
|
||||
expect(users).toEqual([]);
|
||||
});
|
||||
|
||||
it('switches home users and returns profile token', async () => {
|
||||
clientMock.post.mockResolvedValue({ data: '<xml />' });
|
||||
parseStringPromiseMock.mockResolvedValue({
|
||||
user: { $: { authenticationToken: 'profile-token' } },
|
||||
});
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.switchHomeUser('user-1', 'token');
|
||||
|
||||
expect(token).toBe('profile-token');
|
||||
});
|
||||
|
||||
it('returns null when switch response has no auth token', async () => {
|
||||
clientMock.post.mockResolvedValue({ data: { user: { name: 'NoToken' } } });
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.switchHomeUser('user-2', 'token');
|
||||
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it('returns token from direct switch responses', async () => {
|
||||
clientMock.post.mockResolvedValue({ data: { authenticationToken: 'token-1' } });
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.switchHomeUser('user-4', 'token');
|
||||
|
||||
expect(token).toBe('token-1');
|
||||
});
|
||||
|
||||
it('returns token when authenticationToken is nested under user', async () => {
|
||||
clientMock.post.mockResolvedValue({ data: { user: { authenticationToken: 'token-2' } } });
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.switchHomeUser('user-5', 'token');
|
||||
|
||||
expect(token).toBe('token-2');
|
||||
});
|
||||
|
||||
it('returns token when authenticationToken is on root attributes', async () => {
|
||||
clientMock.post.mockResolvedValue({ data: { $: { authenticationToken: 'token-3' } } });
|
||||
|
||||
const service = new PlexService();
|
||||
const token = await service.switchHomeUser('user-6', 'token');
|
||||
|
||||
expect(token).toBe('token-3');
|
||||
});
|
||||
|
||||
it('throws when switching home user with invalid PIN', async () => {
|
||||
clientMock.post.mockRejectedValue({ response: { status: 401 } });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.switchHomeUser('user-3', 'token', '1234')).rejects.toThrow('Invalid PIN');
|
||||
});
|
||||
|
||||
it('throws when switching home user fails for non-auth errors', async () => {
|
||||
clientMock.post.mockRejectedValue({ response: { status: 500 }, message: 'boom' });
|
||||
|
||||
const service = new PlexService();
|
||||
|
||||
await expect(service.switchHomeUser('user-9', 'token')).rejects.toThrow(
|
||||
'Failed to switch to selected profile'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a singleton instance from getPlexService', async () => {
|
||||
const { getPlexService } = await import('@/lib/integrations/plex.service');
|
||||
const serviceA = getPlexService();
|
||||
const serviceB = getPlexService();
|
||||
|
||||
expect(serviceA).toBe(serviceB);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Component: Prowlarr Integration Service Tests
|
||||
* Documentation: documentation/phase3/prowlarr.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
interceptors: {
|
||||
request: {
|
||||
use: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => ({
|
||||
create: vi.fn(() => clientMock),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const configMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
getMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
describe('ProwlarrService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clientMock.get.mockReset();
|
||||
axiosMock.get.mockReset();
|
||||
configMock.get.mockReset();
|
||||
});
|
||||
|
||||
it('filters results for SABnzbd (usenet)', async () => {
|
||||
configMock.get.mockResolvedValue('sabnzbd');
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
guid: 'g1',
|
||||
indexer: 'IndexerA',
|
||||
title: 'Book NZB',
|
||||
size: 100,
|
||||
publishDate: '2024-01-01T00:00:00.000Z',
|
||||
downloadUrl: 'https://example.com/book.nzb',
|
||||
protocol: 'usenet',
|
||||
},
|
||||
{
|
||||
guid: 'g2',
|
||||
indexer: 'IndexerB',
|
||||
title: 'Book Torrent',
|
||||
size: 200,
|
||||
publishDate: '2024-01-02T00:00:00.000Z',
|
||||
magnetUrl: 'magnet:?xt=urn:btih:abc',
|
||||
protocol: 'torrent',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
const results = await service.search('Book');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].downloadUrl).toContain('.nzb');
|
||||
expect(results[0].protocol).toBe('usenet');
|
||||
});
|
||||
|
||||
it('throws when search fails', async () => {
|
||||
configMock.get.mockResolvedValue('qbittorrent');
|
||||
clientMock.get.mockRejectedValue(new Error('bad search'));
|
||||
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
|
||||
await expect(service.search('Book')).rejects.toThrow('Failed to search Prowlarr: bad search');
|
||||
});
|
||||
|
||||
it('filters results for qBittorrent (torrent)', async () => {
|
||||
configMock.get.mockResolvedValue('qbittorrent');
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
guid: 'g1',
|
||||
indexer: 'IndexerA',
|
||||
title: 'Book NZB',
|
||||
size: 100,
|
||||
publishDate: '2024-01-01T00:00:00.000Z',
|
||||
downloadUrl: 'https://example.com/book.nzb',
|
||||
protocol: 'usenet',
|
||||
},
|
||||
{
|
||||
guid: 'g2',
|
||||
indexer: 'IndexerB',
|
||||
title: 'Book Torrent',
|
||||
size: 200,
|
||||
publishDate: '2024-01-02T00:00:00.000Z',
|
||||
magnetUrl: 'magnet:?xt=urn:btih:abc',
|
||||
protocol: 'torrent',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
const results = await service.search('Book');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].downloadUrl).toContain('magnet:?');
|
||||
expect(results[0].protocol).toBe('torrent');
|
||||
});
|
||||
|
||||
it('parses RSS feeds into torrent results', async () => {
|
||||
const xml = `
|
||||
<rss xmlns:torznab="http://torznab.com/schemas/2015/feed">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Great Book M4B 64kbps</title>
|
||||
<link>https://example.com/book.torrent</link>
|
||||
<guid>guid-1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
|
||||
<prowlarrindexer>IndexerA</prowlarrindexer>
|
||||
<torznab:attr name="seeders" value="5" />
|
||||
<torznab:attr name="peers" value="8" />
|
||||
<torznab:attr name="infohash" value="HASH" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
|
||||
axiosMock.get.mockResolvedValue({ data: xml });
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
|
||||
const results = await service.getRssFeed(1);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].seeders).toBe(5);
|
||||
expect(results[0].leechers).toBe(3);
|
||||
expect(results[0].format).toBe('M4B');
|
||||
expect(results[0].bitrate).toBe('64kbps');
|
||||
expect(results[0].hasChapters).toBe(true);
|
||||
});
|
||||
|
||||
it('skips RSS items missing download URLs', async () => {
|
||||
const xml = `
|
||||
<rss xmlns:torznab="http://torznab.com/schemas/2015/feed">
|
||||
<channel>
|
||||
<item>
|
||||
<title>Book Without Link</title>
|
||||
<guid>guid-2</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
|
||||
<prowlarrindexer>IndexerA</prowlarrindexer>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
|
||||
axiosMock.get.mockResolvedValue({ data: xml });
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
|
||||
const results = await service.getRssFeed(2);
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('detects NZB downloads by protocol or URL', () => {
|
||||
expect(ProwlarrService.isNZBResult({ downloadUrl: 'https://x/test.nzb' } as any)).toBe(true);
|
||||
expect(ProwlarrService.isNZBResult({ downloadUrl: 'https://x/getnzb?id=1' } as any)).toBe(true);
|
||||
expect(ProwlarrService.isNZBResult({ downloadUrl: 'magnet:?xt=urn:btih:abc' } as any)).toBe(false);
|
||||
expect(ProwlarrService.isNZBResult({ downloadUrl: 'https://x/file', protocol: 'usenet' } as any)).toBe(true);
|
||||
});
|
||||
|
||||
it('applies category, indexer, and seeder filters', async () => {
|
||||
configMock.get.mockResolvedValue('qbittorrent');
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
guid: 'g1',
|
||||
indexer: 'IndexerA',
|
||||
title: 'Book One',
|
||||
size: 100,
|
||||
publishDate: '2024-01-01T00:00:00.000Z',
|
||||
downloadUrl: 'https://example.com/book.torrent',
|
||||
protocol: 'torrent',
|
||||
seeders: 1,
|
||||
},
|
||||
{
|
||||
guid: 'g2',
|
||||
indexer: 'IndexerB',
|
||||
title: 'Book Two',
|
||||
size: 200,
|
||||
publishDate: '2024-01-02T00:00:00.000Z',
|
||||
downloadUrl: 'https://example.com/book2.torrent',
|
||||
protocol: 'torrent',
|
||||
seeders: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
const results = await service.search('Book', {
|
||||
categories: [3030, 3040],
|
||||
minSeeders: 2,
|
||||
maxResults: 1,
|
||||
indexerIds: [1, 2],
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe('Book Two');
|
||||
expect(clientMock.get).toHaveBeenCalledWith('/search', {
|
||||
params: expect.objectContaining({
|
||||
categories: [3030, 3040],
|
||||
indexerIds: [1, 2],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unfiltered results when protocol filtering fails', async () => {
|
||||
configMock.get
|
||||
.mockResolvedValueOnce('qbittorrent')
|
||||
.mockRejectedValueOnce(new Error('config fail'));
|
||||
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
guid: 'g1',
|
||||
indexer: 'IndexerA',
|
||||
title: 'Book NZB',
|
||||
size: 100,
|
||||
publishDate: '2024-01-01T00:00:00.000Z',
|
||||
downloadUrl: 'https://example.com/book.nzb',
|
||||
protocol: 'usenet',
|
||||
},
|
||||
{
|
||||
guid: 'g2',
|
||||
indexer: 'IndexerB',
|
||||
title: 'Book Torrent',
|
||||
size: 200,
|
||||
publishDate: '2024-01-02T00:00:00.000Z',
|
||||
downloadUrl: 'https://example.com/book.torrent',
|
||||
protocol: 'torrent',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
const results = await service.search('Book');
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('aggregates RSS feeds and ignores failures', async () => {
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
const rssSpy = vi.spyOn(service, 'getRssFeed')
|
||||
.mockRejectedValueOnce(new Error('bad'))
|
||||
.mockResolvedValueOnce([{ guid: 'g1' } as any]);
|
||||
|
||||
const results = await service.getAllRssFeeds([1, 2]);
|
||||
|
||||
expect(rssSpy).toHaveBeenCalledTimes(2);
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips results without download URLs', () => {
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
const result = (service as any).transformResult({
|
||||
guid: 'g1',
|
||||
indexer: 'IndexerA',
|
||||
title: 'No URL',
|
||||
size: 100,
|
||||
publishDate: '2024-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts flags from indexer fields and title metadata', () => {
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
const result = (service as any).transformResult({
|
||||
guid: 'g3',
|
||||
indexer: 'IndexerA',
|
||||
title: 'Book M4A 128kbps',
|
||||
size: 100,
|
||||
publishDate: '2024-01-01T00:00:00.000Z',
|
||||
downloadUrl: 'https://example.com/book.torrent',
|
||||
indexerFlags: ['Trusted', 2],
|
||||
flags: ['Featured', 'Trusted'],
|
||||
});
|
||||
|
||||
expect(result?.flags).toEqual(['Trusted', 'Featured']);
|
||||
expect(result?.format).toBe('M4A');
|
||||
expect(result?.bitrate).toBe('128kbps');
|
||||
});
|
||||
|
||||
it('derives flags from volume factors when no explicit flags exist', () => {
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
const result = (service as any).transformResult({
|
||||
guid: 'g4',
|
||||
indexer: 'IndexerB',
|
||||
title: 'Book MP3',
|
||||
size: 100,
|
||||
publishDate: '2024-01-01T00:00:00.000Z',
|
||||
downloadUrl: 'https://example.com/book.torrent',
|
||||
downloadVolumeFactor: 0,
|
||||
uploadVolumeFactor: 2,
|
||||
});
|
||||
|
||||
expect(result?.flags).toEqual(['Freeleech', 'Double Upload']);
|
||||
expect(result?.format).toBe('MP3');
|
||||
});
|
||||
|
||||
it('marks partial freeleech when download volume factor is reduced', () => {
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
const result = (service as any).transformResult({
|
||||
guid: 'g5',
|
||||
indexer: 'IndexerC',
|
||||
title: 'Book MP3',
|
||||
size: 100,
|
||||
publishDate: '2024-01-01T00:00:00.000Z',
|
||||
downloadUrl: 'https://example.com/book.torrent',
|
||||
downloadVolumeFactor: 0.5,
|
||||
});
|
||||
|
||||
expect(result?.flags).toEqual(['Partial Freeleech']);
|
||||
});
|
||||
|
||||
it('returns null when transformResult throws', () => {
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
const result = (service as any).transformResult({
|
||||
guid: 'g6',
|
||||
indexer: 'IndexerD',
|
||||
title: null,
|
||||
size: 100,
|
||||
publishDate: '2024-01-01T00:00:00.000Z',
|
||||
downloadUrl: 'https://example.com/book.torrent',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns indexers and stats', async () => {
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({ data: [{ id: 1, name: 'IndexerA' }] })
|
||||
.mockResolvedValueOnce({ data: { indexers: [] } });
|
||||
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
const indexers = await service.getIndexers();
|
||||
const stats = await service.getStats();
|
||||
|
||||
expect(indexers).toHaveLength(1);
|
||||
expect(stats.indexers).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns false when connection test fails', async () => {
|
||||
clientMock.get.mockRejectedValue(new Error('health down'));
|
||||
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
const ok = await service.testConnection();
|
||||
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when indexer stats cannot be fetched', async () => {
|
||||
clientMock.get.mockRejectedValue(new Error('no stats'));
|
||||
|
||||
const service = new ProwlarrService('http://prowlarr', 'key');
|
||||
|
||||
await expect(service.getStats()).rejects.toThrow('Failed to get indexer statistics');
|
||||
});
|
||||
|
||||
it('returns a singleton service from configuration', async () => {
|
||||
const originalApiKey = process.env.PROWLARR_API_KEY;
|
||||
delete process.env.PROWLARR_API_KEY;
|
||||
vi.resetModules();
|
||||
|
||||
configMock.getMany.mockResolvedValue({
|
||||
prowlarr_url: 'http://prowlarr',
|
||||
prowlarr_api_key: 'api-key',
|
||||
});
|
||||
clientMock.get.mockResolvedValue({ data: {} });
|
||||
|
||||
const { getProwlarrService } = await import('@/lib/integrations/prowlarr.service');
|
||||
const serviceA = await getProwlarrService();
|
||||
const serviceB = await getProwlarrService();
|
||||
|
||||
expect(serviceA).toBe(serviceB);
|
||||
|
||||
if (originalApiKey === undefined) {
|
||||
delete process.env.PROWLARR_API_KEY;
|
||||
} else {
|
||||
process.env.PROWLARR_API_KEY = originalApiKey;
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when Prowlarr API key is missing', async () => {
|
||||
const originalApiKey = process.env.PROWLARR_API_KEY;
|
||||
delete process.env.PROWLARR_API_KEY;
|
||||
vi.resetModules();
|
||||
|
||||
configMock.getMany.mockResolvedValue({
|
||||
prowlarr_url: 'http://prowlarr',
|
||||
prowlarr_api_key: '',
|
||||
});
|
||||
|
||||
const { getProwlarrService } = await import('@/lib/integrations/prowlarr.service');
|
||||
await expect(getProwlarrService()).rejects.toThrow('Prowlarr API key not configured');
|
||||
|
||||
if (originalApiKey === undefined) {
|
||||
delete process.env.PROWLARR_API_KEY;
|
||||
} else {
|
||||
process.env.PROWLARR_API_KEY = originalApiKey;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns service even when connection test fails', async () => {
|
||||
const originalApiKey = process.env.PROWLARR_API_KEY;
|
||||
delete process.env.PROWLARR_API_KEY;
|
||||
vi.resetModules();
|
||||
|
||||
configMock.getMany.mockResolvedValue({
|
||||
prowlarr_url: 'http://prowlarr',
|
||||
prowlarr_api_key: 'api-key',
|
||||
});
|
||||
clientMock.get.mockRejectedValue(new Error('health down'));
|
||||
|
||||
const { getProwlarrService } = await import('@/lib/integrations/prowlarr.service');
|
||||
const service = await getProwlarrService();
|
||||
|
||||
expect(service).toBeDefined();
|
||||
|
||||
if (originalApiKey === undefined) {
|
||||
delete process.env.PROWLARR_API_KEY;
|
||||
} else {
|
||||
process.env.PROWLARR_API_KEY = originalApiKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* Component: qBittorrent Integration Service Tests
|
||||
* Documentation: documentation/phase3/qbittorrent.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { QBittorrentService, getQBittorrentService, invalidateQBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => ({
|
||||
create: vi.fn(() => clientMock),
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
isAxiosError: (error: any) => Boolean(error?.isAxiosError),
|
||||
}));
|
||||
|
||||
const parseTorrentMock = vi.hoisted(() => vi.fn());
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
getMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('parse-torrent', () => ({
|
||||
default: parseTorrentMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
describe('QBittorrentService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clientMock.get.mockReset();
|
||||
clientMock.post.mockReset();
|
||||
axiosMock.get.mockReset();
|
||||
axiosMock.post.mockReset();
|
||||
parseTorrentMock.mockReset();
|
||||
configServiceMock.getMany.mockReset();
|
||||
invalidateQBittorrentService();
|
||||
});
|
||||
|
||||
it('maps download progress from torrent info', () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const progress = service.getDownloadProgress({
|
||||
progress: 0.42,
|
||||
downloaded: 420,
|
||||
size: 1000,
|
||||
dlspeed: 50,
|
||||
eta: 120,
|
||||
state: 'pausedDL',
|
||||
} as any);
|
||||
|
||||
expect(progress.percent).toBe(42);
|
||||
expect(progress.bytesDownloaded).toBe(420);
|
||||
expect(progress.bytesTotal).toBe(1000);
|
||||
expect(progress.speed).toBe(50);
|
||||
expect(progress.eta).toBe(120);
|
||||
expect(progress.state).toBe('paused');
|
||||
});
|
||||
|
||||
it('extracts info hash from magnet links', () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const hash = (service as any).extractHashFromMagnet(
|
||||
'magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567'
|
||||
);
|
||||
|
||||
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
|
||||
expect((service as any).extractHashFromMagnet('magnet:?xt=urn:btih:')).toBeNull();
|
||||
});
|
||||
|
||||
it('maps allocating state to downloading', () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const progress = service.getDownloadProgress({
|
||||
progress: 0.1,
|
||||
downloaded: 100,
|
||||
size: 1000,
|
||||
dlspeed: 0,
|
||||
eta: 0,
|
||||
state: 'allocating' as any,
|
||||
} as any);
|
||||
|
||||
expect(progress.state).toBe('downloading');
|
||||
});
|
||||
|
||||
it('authenticates and stores a session cookie', async () => {
|
||||
axiosMock.post.mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: 'Ok.',
|
||||
headers: { 'set-cookie': ['SID=abc; Path=/;'] },
|
||||
});
|
||||
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
await service.login();
|
||||
|
||||
expect((service as any).cookie).toBe('SID=abc');
|
||||
});
|
||||
|
||||
it('throws when login response lacks a cookie', async () => {
|
||||
axiosMock.post.mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: 'Ok.',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
|
||||
await expect(service.login()).rejects.toThrow('Failed to authenticate with qBittorrent');
|
||||
});
|
||||
|
||||
it('rejects empty torrent URLs', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
|
||||
await expect(service.addTorrent('')).rejects.toThrow('Invalid download URL');
|
||||
});
|
||||
|
||||
it('skips adding duplicate magnet links', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=dup';
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
vi.spyOn(service as any, 'getTorrent').mockResolvedValue({ hash: 'existing' });
|
||||
|
||||
const hash = await service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567');
|
||||
|
||||
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
|
||||
expect(clientMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds magnet links when not already present', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=add';
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
||||
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||
|
||||
const hash = await service.addTorrent(
|
||||
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||
{ tags: ['tag1', 'tag2'] }
|
||||
);
|
||||
|
||||
expect(hash).toBe('0123456789abcdef0123456789abcdef01234567');
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/add',
|
||||
expect.any(URLSearchParams),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when magnet link is invalid', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=badmagnet';
|
||||
|
||||
await expect(
|
||||
(service as any).addMagnetLink('magnet:?xt=urn:btih:', 'readmeabook')
|
||||
).rejects.toThrow('Invalid magnet link');
|
||||
});
|
||||
|
||||
it('throws when qBittorrent rejects magnet uploads', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=rejected';
|
||||
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
||||
clientMock.post.mockResolvedValue({ data: 'Nope' });
|
||||
|
||||
await expect(
|
||||
(service as any).addMagnetLink(
|
||||
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||
'readmeabook'
|
||||
)
|
||||
).rejects.toThrow('qBittorrent rejected magnet link');
|
||||
});
|
||||
|
||||
it('re-authenticates after a 403 and retries adding torrents', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=old';
|
||||
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
const loginSpy = vi.spyOn(service, 'login').mockResolvedValue();
|
||||
const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink')
|
||||
.mockRejectedValueOnce({ isAxiosError: true, response: { status: 403 } })
|
||||
.mockResolvedValueOnce('rehash');
|
||||
|
||||
const hash = await service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567');
|
||||
|
||||
expect(hash).toBe('rehash');
|
||||
expect(loginSpy).toHaveBeenCalledTimes(1);
|
||||
expect(addMagnetSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('follows redirect to magnet link when downloading torrent files', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=redir';
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink').mockResolvedValue('redirect-hash');
|
||||
|
||||
axiosMock.get.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 302, headers: { location: 'magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01' } },
|
||||
});
|
||||
|
||||
const hash = await service.addTorrent('http://example.com/file.torrent');
|
||||
|
||||
expect(hash).toBe('redirect-hash');
|
||||
expect(addMagnetSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats magnet response bodies as magnet links', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=body';
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
const addMagnetSpy = vi.spyOn(service as any, 'addMagnetLink').mockResolvedValue('body-hash');
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: Buffer.from('magnet:?xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01'),
|
||||
});
|
||||
|
||||
const hash = await service.addTorrent('http://example.com/file.torrent');
|
||||
|
||||
expect(hash).toBe('body-hash');
|
||||
expect(addMagnetSpy).toHaveBeenCalled();
|
||||
expect(parseTorrentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds torrent files after parsing successfully', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=ok';
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
|
||||
parseTorrentMock.mockResolvedValueOnce({ infoHash: 'hash-1', name: 'Book' });
|
||||
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||
|
||||
const hash = await service.addTorrent('http://example.com/file.torrent');
|
||||
|
||||
expect(hash).toBe('hash-1');
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/add',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ maxBodyLength: Infinity })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for invalid redirect locations when fetching torrents', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
|
||||
axiosMock.get.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 302, headers: { location: 'ftp://bad' } },
|
||||
message: 'redirect',
|
||||
});
|
||||
|
||||
await expect(
|
||||
(service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
|
||||
).rejects.toThrow('Invalid redirect location');
|
||||
});
|
||||
|
||||
it('throws when torrent file parsing fails directly', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
|
||||
parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent'));
|
||||
|
||||
await expect(
|
||||
(service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
|
||||
).rejects.toThrow('Invalid .torrent file - failed to parse');
|
||||
});
|
||||
|
||||
it('throws when torrent file has no info hash', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
|
||||
parseTorrentMock.mockResolvedValueOnce({ infoHash: null });
|
||||
|
||||
await expect(
|
||||
(service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
|
||||
).rejects.toThrow('Failed to extract info_hash');
|
||||
});
|
||||
|
||||
it('throws when qBittorrent rejects torrent file uploads', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=reject';
|
||||
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('torrent') });
|
||||
parseTorrentMock.mockResolvedValueOnce({ infoHash: 'hash-2', name: 'Book' });
|
||||
clientMock.post.mockResolvedValue({ data: 'Nope' });
|
||||
|
||||
await expect(
|
||||
(service as any).addTorrentFile('http://example.com/file.torrent', 'readmeabook')
|
||||
).rejects.toThrow('qBittorrent rejected .torrent file');
|
||||
});
|
||||
|
||||
it('throws when torrent parsing fails', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=parse';
|
||||
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||
|
||||
axiosMock.get.mockResolvedValueOnce({ data: Buffer.from('not-a-torrent') });
|
||||
parseTorrentMock.mockRejectedValueOnce(new Error('bad torrent'));
|
||||
|
||||
await expect(service.addTorrent('http://example.com/file.torrent')).rejects.toThrow(
|
||||
'Failed to add torrent to qBittorrent'
|
||||
);
|
||||
});
|
||||
|
||||
it('creates categories when missing', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads');
|
||||
(service as any).cookie = 'SID=newcat';
|
||||
clientMock.get.mockResolvedValue({ data: {} });
|
||||
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||
|
||||
await (service as any).ensureCategory('readmeabook');
|
||||
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/createCategory',
|
||||
expect.any(URLSearchParams),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when ensuring categories fails', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=catfail';
|
||||
clientMock.get.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 500 },
|
||||
});
|
||||
|
||||
await expect((service as any).ensureCategory('readmeabook')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates category when save path mismatches', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads');
|
||||
(service as any).cookie = 'SID=cat';
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
readmeabook: { savePath: '/old' },
|
||||
},
|
||||
});
|
||||
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||
|
||||
await (service as any).ensureCategory('readmeabook');
|
||||
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/editCategory',
|
||||
expect.any(URLSearchParams),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not update category when save path matches', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass', '/downloads');
|
||||
(service as any).cookie = 'SID=cat-ok';
|
||||
clientMock.get.mockResolvedValue({
|
||||
data: {
|
||||
readmeabook: { savePath: '/downloads' },
|
||||
},
|
||||
});
|
||||
|
||||
await (service as any).ensureCategory('readmeabook');
|
||||
|
||||
expect(clientMock.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pauses and resumes torrents', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=pause';
|
||||
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||
|
||||
await service.pauseTorrent('hash-1');
|
||||
await service.resumeTorrent('hash-1');
|
||||
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/pause',
|
||||
expect.any(URLSearchParams),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/resume',
|
||||
expect.any(URLSearchParams),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when torrent state updates fail', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=fail';
|
||||
clientMock.post.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(service.pauseTorrent('hash-1')).rejects.toThrow('Failed to pause torrent');
|
||||
await expect(service.resumeTorrent('hash-1')).rejects.toThrow('Failed to resume torrent');
|
||||
await expect(service.deleteTorrent('hash-1', false)).rejects.toThrow('Failed to delete torrent');
|
||||
await expect(service.setCategory('hash-1', 'books')).rejects.toThrow('Failed to set torrent category');
|
||||
});
|
||||
|
||||
it('sets categories, deletes torrents, and fetches files', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=ops';
|
||||
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||
clientMock.get.mockResolvedValue({ data: [{ name: 'file1' }] });
|
||||
|
||||
await service.setCategory('hash-1', 'books');
|
||||
await service.deleteTorrent('hash-1', true);
|
||||
const files = await service.getFiles('hash-1');
|
||||
|
||||
expect(files).toEqual([{ name: 'file1' }]);
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/setCategory',
|
||||
expect.any(URLSearchParams),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(clientMock.post).toHaveBeenCalledWith(
|
||||
'/torrents/delete',
|
||||
expect.any(URLSearchParams),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when fetching torrent files fails', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=files';
|
||||
clientMock.get.mockRejectedValue(new Error('no files'));
|
||||
|
||||
await expect(service.getFiles('hash-1')).rejects.toThrow('Failed to get torrent files');
|
||||
});
|
||||
|
||||
it('throws when torrent is not found', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=missing';
|
||||
clientMock.get.mockResolvedValueOnce({ data: [] });
|
||||
|
||||
await expect(service.getTorrent('hash-404')).rejects.toThrow('Torrent hash-404 not found');
|
||||
});
|
||||
|
||||
it('returns error when getTorrents fails', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=list';
|
||||
clientMock.get.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(service.getTorrents()).rejects.toThrow('Failed to get torrents from qBittorrent');
|
||||
});
|
||||
|
||||
it('returns torrent lists with a category filter', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
(service as any).cookie = 'SID=list';
|
||||
clientMock.get.mockResolvedValueOnce({ data: [{ hash: 'h1' }] });
|
||||
|
||||
const torrents = await service.getTorrents('books');
|
||||
|
||||
expect(torrents).toEqual([{ hash: 'h1' }]);
|
||||
expect(clientMock.get).toHaveBeenCalledWith(
|
||||
'/torrents/info',
|
||||
expect.objectContaining({ params: { category: 'books' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('returns unknown state for unrecognized torrent states', () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const progress = service.getDownloadProgress({
|
||||
progress: 0,
|
||||
downloaded: 0,
|
||||
size: 1,
|
||||
dlspeed: 0,
|
||||
eta: 0,
|
||||
state: 'weird' as any,
|
||||
} as any);
|
||||
|
||||
expect(progress.state).toBe('unknown');
|
||||
});
|
||||
|
||||
it('throws specific errors for invalid credentials in testConnectionWithCredentials', async () => {
|
||||
axiosMock.post.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: 'Ok.',
|
||||
headers: { 'set-cookie': ['SID=abc; Path=/;'] },
|
||||
});
|
||||
axiosMock.get.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 401 },
|
||||
config: { url: 'http://qb/api/v2/app/version' },
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
|
||||
await expect(
|
||||
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'bad')
|
||||
).rejects.toThrow('Authentication failed');
|
||||
});
|
||||
|
||||
it('returns version on successful credential test', async () => {
|
||||
axiosMock.post.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: 'Ok.',
|
||||
headers: { 'set-cookie': ['SID=abc; Path=/;'] },
|
||||
});
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: 'v4.6.0',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const version = await QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass');
|
||||
|
||||
expect(version).toBe('v4.6.0');
|
||||
});
|
||||
|
||||
it('throws when test connection receives no cookies', async () => {
|
||||
axiosMock.post.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: 'Ok.',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
|
||||
).rejects.toThrow('Failed to authenticate - no session cookie received');
|
||||
});
|
||||
|
||||
it('throws SSL-specific errors for certificate failures', async () => {
|
||||
axiosMock.post.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
code: 'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
message: 'self signed',
|
||||
config: { url: 'https://qb/api/v2/auth/login' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
QBittorrentService.testConnectionWithCredentials('https://qb', 'user', 'pass', true)
|
||||
).rejects.toThrow('SSL certificate verification failed');
|
||||
});
|
||||
|
||||
it('throws when connection is refused', async () => {
|
||||
axiosMock.post.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
code: 'ECONNREFUSED',
|
||||
message: 'refused',
|
||||
config: { url: 'http://qb/api/v2/auth/login' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
|
||||
).rejects.toThrow('Connection refused');
|
||||
});
|
||||
|
||||
it('throws when server returns 404', async () => {
|
||||
axiosMock.post.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 404 },
|
||||
message: 'Not found',
|
||||
config: { url: 'http://qb/api/v2/auth/login' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
|
||||
).rejects.toThrow('qBittorrent Web UI not found');
|
||||
});
|
||||
|
||||
it('throws on qBittorrent server errors', async () => {
|
||||
axiosMock.post.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 503 },
|
||||
message: 'Server error',
|
||||
config: { url: 'http://qb/api/v2/auth/login' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
QBittorrentService.testConnectionWithCredentials('http://qb', 'user', 'pass')
|
||||
).rejects.toThrow('qBittorrent server error');
|
||||
});
|
||||
|
||||
it('throws when qBittorrent configuration is incomplete', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
download_client_url: null,
|
||||
download_client_username: null,
|
||||
download_client_password: null,
|
||||
download_dir: null,
|
||||
download_client_disable_ssl_verify: 'false',
|
||||
});
|
||||
|
||||
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent is not fully configured');
|
||||
});
|
||||
|
||||
it('returns a cached instance after successful initialization', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
download_client_url: 'http://qb',
|
||||
download_client_username: 'user',
|
||||
download_client_password: 'pass',
|
||||
download_dir: '/downloads',
|
||||
download_client_disable_ssl_verify: 'false',
|
||||
});
|
||||
|
||||
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(true);
|
||||
|
||||
const first = await getQBittorrentService();
|
||||
const second = await getQBittorrentService();
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(configServiceMock.getMany).toHaveBeenCalledTimes(1);
|
||||
|
||||
testConnectionSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('throws when connection test fails during service creation', async () => {
|
||||
configServiceMock.getMany.mockResolvedValue({
|
||||
download_client_url: 'http://qb',
|
||||
download_client_username: 'user',
|
||||
download_client_password: 'pass',
|
||||
download_dir: '/downloads',
|
||||
download_client_disable_ssl_verify: 'false',
|
||||
});
|
||||
|
||||
const testConnectionSpy = vi.spyOn(QBittorrentService.prototype, 'testConnection').mockResolvedValue(false);
|
||||
|
||||
await expect(getQBittorrentService()).rejects.toThrow('qBittorrent connection test failed');
|
||||
|
||||
testConnectionSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns false when connection test fails', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const loginSpy = vi.spyOn(service, 'login').mockRejectedValue(new Error('bad auth'));
|
||||
|
||||
const ok = await service.testConnection();
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(loginSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns true when connection test succeeds', async () => {
|
||||
const service = new QBittorrentService('http://qb', 'user', 'pass');
|
||||
const loginSpy = vi.spyOn(service, 'login').mockResolvedValue();
|
||||
|
||||
const ok = await service.testConnection();
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(loginSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* Component: SABnzbd Integration Service Tests
|
||||
* Documentation: documentation/phase3/sabnzbd.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SABnzbdService, getSABnzbdService, invalidateSABnzbdService } from '@/lib/integrations/sabnzbd.service';
|
||||
|
||||
const clientMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => ({
|
||||
create: vi.fn(() => clientMock),
|
||||
}));
|
||||
|
||||
const configServiceMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configServiceMock,
|
||||
}));
|
||||
|
||||
describe('SABnzbdService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clientMock.get.mockReset();
|
||||
configServiceMock.get.mockReset();
|
||||
invalidateSABnzbdService();
|
||||
});
|
||||
|
||||
it('fails connection when API key is missing', async () => {
|
||||
const service = new SABnzbdService('http://sab', '');
|
||||
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('API key is required');
|
||||
expect(clientMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a friendly error for invalid API key', async () => {
|
||||
clientMock.get.mockResolvedValueOnce({
|
||||
data: { status: false, error: 'API Key Incorrect' },
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'bad-key');
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid API key');
|
||||
expect(clientMock.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns non-API key errors from the server', async () => {
|
||||
clientMock.get.mockResolvedValueOnce({
|
||||
data: { status: false, error: 'No permissions' },
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'bad-key');
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('No permissions');
|
||||
});
|
||||
|
||||
it('returns version when connection succeeds', async () => {
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({ data: { status: true } })
|
||||
.mockResolvedValueOnce({ data: { version: '4.0.0' } });
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'good-key');
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.version).toBe('4.0.0');
|
||||
expect(clientMock.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns SSL error message when certificate issues occur', async () => {
|
||||
clientMock.get.mockRejectedValueOnce(new Error('certificate error'));
|
||||
|
||||
const service = new SABnzbdService('https://sab', 'key');
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('SSL/TLS certificate error');
|
||||
});
|
||||
|
||||
it('returns a friendly error on connection refused', async () => {
|
||||
clientMock.get.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('adds NZB with mapped priority', async () => {
|
||||
clientMock.get.mockResolvedValueOnce({
|
||||
data: { status: true, nzo_ids: ['nzb-1'] },
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
const nzbId = await service.addNZB('https://example.com/book.nzb', {
|
||||
category: 'books',
|
||||
priority: 'high',
|
||||
});
|
||||
|
||||
const params = clientMock.get.mock.calls[0][1].params;
|
||||
expect(nzbId).toBe('nzb-1');
|
||||
expect(params.cat).toBe('books');
|
||||
expect(params.priority).toBe('1');
|
||||
});
|
||||
|
||||
it('adds NZB with force priority', async () => {
|
||||
clientMock.get.mockResolvedValueOnce({
|
||||
data: { status: true, nzo_ids: ['nzb-9'] },
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
await service.addNZB('https://example.com/book.nzb', { priority: 'force' });
|
||||
|
||||
const params = clientMock.get.mock.calls[0][1].params;
|
||||
expect(params.priority).toBe('2');
|
||||
});
|
||||
|
||||
it('returns queue item info when NZB is active', async () => {
|
||||
clientMock.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
queue: {
|
||||
slots: [
|
||||
{
|
||||
nzo_id: 'nzb-2',
|
||||
filename: 'Queue Book',
|
||||
mb: '10',
|
||||
mbleft: '5',
|
||||
percentage: '50',
|
||||
status: 'Paused',
|
||||
timeleft: '0:00:10',
|
||||
cat: 'readmeabook',
|
||||
priority: 'Normal',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
const info = await service.getNZB('nzb-2');
|
||||
|
||||
expect(info?.nzbId).toBe('nzb-2');
|
||||
expect(info?.progress).toBe(0.5);
|
||||
expect(info?.status).toBe('paused');
|
||||
expect(info?.size).toBe(10 * 1024 * 1024);
|
||||
expect(info?.timeLeft).toBe(10);
|
||||
});
|
||||
|
||||
it('maps queue slots from getQueue', async () => {
|
||||
clientMock.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
queue: {
|
||||
slots: [
|
||||
{
|
||||
nzo_id: 'nzb-10',
|
||||
filename: 'Queue Book',
|
||||
mb: '5',
|
||||
mbleft: '2',
|
||||
percentage: '40',
|
||||
status: 'Queued',
|
||||
timeleft: '0:01:00',
|
||||
cat: 'readmeabook',
|
||||
priority: 'Normal',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
const queue = await service.getQueue();
|
||||
|
||||
expect(queue[0]).toEqual(expect.objectContaining({
|
||||
nzbId: 'nzb-10',
|
||||
name: 'Queue Book',
|
||||
size: 5,
|
||||
sizeLeft: 2,
|
||||
percentage: 40,
|
||||
status: 'Queued',
|
||||
}));
|
||||
});
|
||||
|
||||
it('maps history slots from getHistory', async () => {
|
||||
clientMock.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
history: {
|
||||
slots: [
|
||||
{
|
||||
nzo_id: 'nzb-11',
|
||||
name: 'History Book',
|
||||
category: 'readmeabook',
|
||||
status: 'Failed',
|
||||
bytes: '1024',
|
||||
fail_message: 'Failed',
|
||||
storage: '/downloads',
|
||||
completed: '1700000001',
|
||||
download_time: '60',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
const history = await service.getHistory(1);
|
||||
|
||||
expect(history[0]).toEqual(expect.objectContaining({
|
||||
nzbId: 'nzb-11',
|
||||
status: 'Failed',
|
||||
bytes: '1024',
|
||||
failMessage: 'Failed',
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns history item info when NZB has completed', async () => {
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({ data: { queue: { slots: [] } } })
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
history: {
|
||||
slots: [
|
||||
{
|
||||
nzo_id: 'nzb-3',
|
||||
name: 'History Book',
|
||||
category: 'readmeabook',
|
||||
status: 'Completed',
|
||||
bytes: '2048',
|
||||
fail_message: '',
|
||||
storage: '/downloads/book',
|
||||
completed: '1700000000',
|
||||
download_time: '60',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
const info = await service.getNZB('nzb-3');
|
||||
|
||||
expect(info?.nzbId).toBe('nzb-3');
|
||||
expect(info?.progress).toBe(1);
|
||||
expect(info?.status).toBe('completed');
|
||||
expect(info?.downloadPath).toBe('/downloads/book');
|
||||
expect(info?.completedAt?.getTime()).toBe(1700000000 * 1000);
|
||||
});
|
||||
|
||||
it('returns history item info when NZB has failed', async () => {
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({ data: { queue: { slots: [] } } })
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
history: {
|
||||
slots: [
|
||||
{
|
||||
nzo_id: 'nzb-12',
|
||||
name: 'Failed Book',
|
||||
category: 'readmeabook',
|
||||
status: 'Failed',
|
||||
bytes: '2048',
|
||||
fail_message: 'Bad nzb',
|
||||
storage: '/downloads/book',
|
||||
completed: '1700000002',
|
||||
download_time: '30',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
const info = await service.getNZB('nzb-12');
|
||||
|
||||
expect(info?.status).toBe('failed');
|
||||
expect(info?.errorMessage).toBe('Bad nzb');
|
||||
});
|
||||
|
||||
it('maps repairing status in download progress', () => {
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
const progress = service.getDownloadProgress({
|
||||
nzbId: 'nzb-4',
|
||||
name: 'Repairing Book',
|
||||
size: 1,
|
||||
sizeLeft: 1,
|
||||
percentage: 100,
|
||||
status: 'Repairing',
|
||||
timeLeft: '0:00:00',
|
||||
category: 'readmeabook',
|
||||
priority: 'Normal',
|
||||
});
|
||||
|
||||
expect(progress.state).toBe('repairing');
|
||||
expect(progress.percent).toBe(1);
|
||||
});
|
||||
|
||||
it('maps queued and extracting status in download progress', () => {
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
const queued = service.getDownloadProgress({
|
||||
nzbId: 'nzb-5',
|
||||
name: 'Queued Book',
|
||||
size: 2,
|
||||
sizeLeft: 2,
|
||||
percentage: 0,
|
||||
status: 'Queued',
|
||||
timeLeft: '0:10:00',
|
||||
category: 'readmeabook',
|
||||
priority: 'Normal',
|
||||
});
|
||||
|
||||
const extracting = service.getDownloadProgress({
|
||||
nzbId: 'nzb-6',
|
||||
name: 'Extracting Book',
|
||||
size: 2,
|
||||
sizeLeft: 1,
|
||||
percentage: 50,
|
||||
status: 'Extracting',
|
||||
timeLeft: '0:05:00',
|
||||
category: 'readmeabook',
|
||||
priority: 'Normal',
|
||||
});
|
||||
|
||||
expect(queued.state).toBe('queued');
|
||||
expect(extracting.state).toBe('extracting');
|
||||
});
|
||||
|
||||
it('maps completed status when percentage is 100', () => {
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
const progress = service.getDownloadProgress({
|
||||
nzbId: 'nzb-7',
|
||||
name: 'Done Book',
|
||||
size: 1,
|
||||
sizeLeft: 0,
|
||||
percentage: 100,
|
||||
status: 'Downloading',
|
||||
timeLeft: '0:00:00',
|
||||
category: 'readmeabook',
|
||||
priority: 'Normal',
|
||||
});
|
||||
|
||||
expect(progress.state).toBe('completed');
|
||||
expect(progress.percent).toBe(1);
|
||||
});
|
||||
|
||||
it('creates the default category when missing', async () => {
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({
|
||||
data: { config: { version: '1', categories: {} } },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { status: true } });
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key', 'readmeabook');
|
||||
await service.ensureCategory('/downloads');
|
||||
|
||||
expect(clientMock.get).toHaveBeenCalledWith('/api', expect.objectContaining({
|
||||
params: expect.objectContaining({ mode: 'set_config', keyword: 'readmeabook' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('swallows errors when ensuring categories fails', async () => {
|
||||
const service = new SABnzbdService('http://sab', 'key', 'readmeabook');
|
||||
const configSpy = vi.spyOn(service, 'getConfig').mockRejectedValue(new Error('bad config'));
|
||||
|
||||
await expect(service.ensureCategory('/downloads')).resolves.toBeUndefined();
|
||||
|
||||
configSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not create category when it already exists', async () => {
|
||||
clientMock.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
config: {
|
||||
version: '1',
|
||||
categories: { readmeabook: { dir: '/downloads' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key', 'readmeabook');
|
||||
await service.ensureCategory('/downloads');
|
||||
|
||||
expect(clientMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(clientMock.get.mock.calls[0][1].params.mode).toBe('get_config');
|
||||
});
|
||||
it('throws when addNZB reports a failure', async () => {
|
||||
clientMock.get.mockResolvedValueOnce({
|
||||
data: { status: false, error: 'Bad NZB' },
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
|
||||
await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('Bad NZB');
|
||||
});
|
||||
|
||||
it('throws when SABnzbd returns no NZB IDs', async () => {
|
||||
clientMock.get.mockResolvedValueOnce({
|
||||
data: { status: true, nzo_ids: [] },
|
||||
});
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
|
||||
await expect(service.addNZB('https://example.com/book.nzb')).rejects.toThrow('did not return an NZB ID');
|
||||
});
|
||||
|
||||
it('returns null when NZB is not found in queue or history', async () => {
|
||||
clientMock.get
|
||||
.mockResolvedValueOnce({ data: { queue: { slots: [] } } })
|
||||
.mockResolvedValueOnce({ data: { history: { slots: [] } } });
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
const info = await service.getNZB('missing');
|
||||
|
||||
expect(info).toBeNull();
|
||||
});
|
||||
|
||||
it('returns an error message for connection timeouts', async () => {
|
||||
clientMock.get.mockRejectedValueOnce(new Error('ETIMEDOUT'));
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
const result = await service.testConnection();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('timed out');
|
||||
});
|
||||
|
||||
it('throws when version is missing from response', async () => {
|
||||
clientMock.get.mockResolvedValueOnce({ data: {} });
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
|
||||
await expect(service.getVersion()).rejects.toThrow('Failed to get SABnzbd version');
|
||||
});
|
||||
|
||||
it('throws when config payload is missing', async () => {
|
||||
clientMock.get.mockResolvedValueOnce({ data: {} });
|
||||
|
||||
const service = new SABnzbdService('http://sab', 'key');
|
||||
|
||||
await expect(service.getConfig()).rejects.toThrow('Failed to get SABnzbd configuration');
|
||||
});
|
||||
|
||||
it('creates a singleton service from config', async () => {
|
||||
configServiceMock.get.mockImplementation(async (key: string) => {
|
||||
switch (key) {
|
||||
case 'download_client_url':
|
||||
return 'http://sab';
|
||||
case 'download_client_password':
|
||||
return 'api-key';
|
||||
case 'sabnzbd_category':
|
||||
return 'books';
|
||||
case 'download_client_disable_ssl_verify':
|
||||
return 'false';
|
||||
case 'download_dir':
|
||||
return '/downloads';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const ensureSpy = vi.spyOn(SABnzbdService.prototype, 'ensureCategory').mockResolvedValue();
|
||||
|
||||
const service = await getSABnzbdService();
|
||||
const again = await getSABnzbdService();
|
||||
|
||||
expect(service).toBe(again);
|
||||
expect(ensureSpy).toHaveBeenCalledWith('/downloads');
|
||||
|
||||
ensureSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Component: Auth Middleware Tests
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const verifyAccessTokenMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/jwt', () => ({
|
||||
verifyAccessToken: verifyAccessTokenMock,
|
||||
}));
|
||||
|
||||
const makeRequest = (authHeader?: string) => ({
|
||||
headers: {
|
||||
get: (key: string) => {
|
||||
if (key.toLowerCase() === 'authorization') {
|
||||
return authHeader ?? null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('auth middleware', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('rejects requests without a token', async () => {
|
||||
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||
|
||||
const response = await requireAuth(makeRequest() as any, vi.fn());
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('rejects invalid tokens', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue(null);
|
||||
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||
|
||||
const response = await requireAuth(makeRequest('Bearer badtoken') as any, vi.fn());
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.message).toMatch(/invalid/i);
|
||||
});
|
||||
|
||||
it('rejects tokens for missing users', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'user',
|
||||
role: 'user',
|
||||
iat: 1,
|
||||
exp: 2,
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue(null);
|
||||
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||
|
||||
const response = await requireAuth(makeRequest('Bearer token') as any, vi.fn());
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(payload.message).toMatch(/user not found/i);
|
||||
});
|
||||
|
||||
it('passes authenticated requests to handler', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'user',
|
||||
role: 'user',
|
||||
iat: 1,
|
||||
exp: 2,
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1' });
|
||||
const { requireAuth } = await import('@/lib/middleware/auth');
|
||||
|
||||
const handler = vi.fn(async (req: any) =>
|
||||
NextResponse.json({ ok: true, userId: req.user?.id })
|
||||
);
|
||||
const response = await requireAuth(makeRequest('Bearer token') as any, handler);
|
||||
const payload = await response.json();
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(payload.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('requires admin role', async () => {
|
||||
const { requireAdmin } = await import('@/lib/middleware/auth');
|
||||
|
||||
const noUserResponse = await requireAdmin({} as any, vi.fn());
|
||||
expect(noUserResponse.status).toBe(401);
|
||||
|
||||
const response = await requireAdmin({ user: { role: 'user' } } as any, vi.fn());
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows admin users', async () => {
|
||||
const { requireAdmin } = await import('@/lib/middleware/auth');
|
||||
|
||||
const handler = vi.fn(async () => NextResponse.json({ ok: true }));
|
||||
const response = await requireAdmin({ user: { role: 'admin' } } as any, handler);
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('requires local admin with setup flag', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
isSetupAdmin: true,
|
||||
plexId: 'local-admin',
|
||||
});
|
||||
const { requireLocalAdmin } = await import('@/lib/middleware/auth');
|
||||
|
||||
const handler = vi.fn(async () => NextResponse.json({ ok: true }));
|
||||
const response = await requireLocalAdmin(
|
||||
{ user: { id: 'user-1', role: 'admin' } } as any,
|
||||
handler
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects non-local admins', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
isSetupAdmin: false,
|
||||
plexId: 'plex-user',
|
||||
});
|
||||
const { requireLocalAdmin } = await import('@/lib/middleware/auth');
|
||||
|
||||
const response = await requireLocalAdmin(
|
||||
{ user: { id: 'user-1', role: 'admin' } } as any,
|
||||
vi.fn()
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('checks local admin helper', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
isSetupAdmin: true,
|
||||
plexId: 'local-admin',
|
||||
});
|
||||
const { isLocalAdmin } = await import('@/lib/middleware/auth');
|
||||
|
||||
const result = await isLocalAdmin('user-1');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns current user from token', async () => {
|
||||
verifyAccessTokenMock.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'user',
|
||||
role: 'admin',
|
||||
iat: 1,
|
||||
exp: 2,
|
||||
});
|
||||
const { getCurrentUser, isAdmin } = await import('@/lib/middleware/auth');
|
||||
|
||||
const payload = getCurrentUser(makeRequest('Bearer token') as any);
|
||||
expect(payload?.sub).toBe('user-1');
|
||||
expect(isAdmin(payload)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Component: Audible Refresh Processor Tests
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const audibleServiceMock = vi.hoisted(() => ({
|
||||
getPopularAudiobooks: vi.fn(),
|
||||
getNewReleases: vi.fn(),
|
||||
}));
|
||||
const thumbnailCacheMock = vi.hoisted(() => ({
|
||||
cacheThumbnail: vi.fn(),
|
||||
cleanupUnusedThumbnails: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
getAudibleService: () => audibleServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/thumbnail-cache.service', () => ({
|
||||
getThumbnailCacheService: () => thumbnailCacheMock,
|
||||
}));
|
||||
|
||||
describe('processAudibleRefresh', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('refreshes popular and new releases, caching thumbnails', async () => {
|
||||
const popular = [
|
||||
{
|
||||
asin: 'ASIN-1',
|
||||
title: 'Popular One',
|
||||
author: 'Author A',
|
||||
narrator: 'Narrator A',
|
||||
description: 'Desc',
|
||||
coverArtUrl: 'http://image/1',
|
||||
durationMinutes: 120,
|
||||
releaseDate: '2024-01-01',
|
||||
rating: 4.8,
|
||||
genres: ['fiction'],
|
||||
},
|
||||
{
|
||||
asin: 'ASIN-2',
|
||||
title: 'Popular Two',
|
||||
author: 'Author B',
|
||||
narrator: 'Narrator B',
|
||||
description: 'Desc',
|
||||
coverArtUrl: null,
|
||||
durationMinutes: 90,
|
||||
releaseDate: null,
|
||||
rating: null,
|
||||
genres: [],
|
||||
},
|
||||
];
|
||||
const newReleases = [
|
||||
{
|
||||
asin: 'ASIN-3',
|
||||
title: 'New Release',
|
||||
author: 'Author C',
|
||||
narrator: 'Narrator C',
|
||||
description: 'Desc',
|
||||
coverArtUrl: 'http://image/3',
|
||||
durationMinutes: 200,
|
||||
releaseDate: '2024-02-02',
|
||||
rating: 4.2,
|
||||
genres: ['history'],
|
||||
},
|
||||
];
|
||||
|
||||
audibleServiceMock.getPopularAudiobooks.mockResolvedValue(popular);
|
||||
audibleServiceMock.getNewReleases.mockResolvedValue(newReleases);
|
||||
thumbnailCacheMock.cacheThumbnail.mockResolvedValue('cached/path.jpg');
|
||||
thumbnailCacheMock.cleanupUnusedThumbnails.mockResolvedValue(2);
|
||||
prismaMock.audibleCache.updateMany.mockResolvedValue({ count: 1 });
|
||||
prismaMock.audibleCache.upsert.mockResolvedValue({});
|
||||
prismaMock.audibleCache.findMany.mockResolvedValue([
|
||||
{ asin: 'ASIN-1' },
|
||||
{ asin: 'ASIN-2' },
|
||||
{ asin: 'ASIN-3' },
|
||||
]);
|
||||
|
||||
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
|
||||
const result = await processAudibleRefresh({ jobId: 'job-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.popularSaved).toBe(2);
|
||||
expect(result.newReleasesSaved).toBe(1);
|
||||
expect(prismaMock.audibleCache.updateMany).toHaveBeenCalled();
|
||||
expect(prismaMock.audibleCache.upsert).toHaveBeenCalledTimes(3);
|
||||
expect(thumbnailCacheMock.cacheThumbnail).toHaveBeenCalledWith('ASIN-1', 'http://image/1');
|
||||
expect(thumbnailCacheMock.cacheThumbnail).toHaveBeenCalledWith('ASIN-3', 'http://image/3');
|
||||
expect(thumbnailCacheMock.cleanupUnusedThumbnails).toHaveBeenCalled();
|
||||
|
||||
const activeSet = thumbnailCacheMock.cleanupUnusedThumbnails.mock.calls[0][0] as Set<string>;
|
||||
expect(Array.from(activeSet).sort()).toEqual(['ASIN-1', 'ASIN-2', 'ASIN-3']);
|
||||
});
|
||||
|
||||
it('rethrows fatal errors', async () => {
|
||||
prismaMock.audibleCache.updateMany.mockRejectedValue(new Error('DB down'));
|
||||
|
||||
const { processAudibleRefresh } = await import('@/lib/processors/audible-refresh.processor');
|
||||
await expect(processAudibleRefresh({ jobId: 'job-2' })).rejects.toThrow('DB down');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Component: Cleanup Seeded Torrents Processor Tests
|
||||
* Documentation: documentation/backend/services/scheduler.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 qbtMock = vi.hoisted(() => ({
|
||||
getTorrent: vi.fn(),
|
||||
deleteTorrent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
getQBittorrentService: async () => qbtMock,
|
||||
}));
|
||||
|
||||
describe('processCleanupSeededTorrents', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('skips when no indexer configuration is found', async () => {
|
||||
configMock.get.mockResolvedValue(null);
|
||||
|
||||
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
|
||||
const result = await processCleanupSeededTorrents({ jobId: 'job-1' });
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(prismaMock.request.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hard deletes orphaned SABnzbd requests', async () => {
|
||||
configMock.get.mockResolvedValue(
|
||||
JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 30 }])
|
||||
);
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-1',
|
||||
deletedAt: new Date(),
|
||||
downloadHistory: [
|
||||
{
|
||||
selected: true,
|
||||
downloadStatus: 'completed',
|
||||
indexerName: 'IndexerA',
|
||||
nzbId: 'nzb-1',
|
||||
torrentHash: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
prismaMock.request.delete.mockResolvedValue({});
|
||||
|
||||
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
|
||||
const result = await processCleanupSeededTorrents({ jobId: 'job-2' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-1' } });
|
||||
expect(qbtMock.getTorrent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes torrents when seeding requirements are met with no shared downloads', async () => {
|
||||
configMock.get.mockResolvedValue(
|
||||
JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 30 }])
|
||||
);
|
||||
prismaMock.request.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-2',
|
||||
deletedAt: null,
|
||||
downloadHistory: [
|
||||
{
|
||||
selected: true,
|
||||
downloadStatus: 'completed',
|
||||
indexerName: 'IndexerA',
|
||||
torrentHash: 'hash-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
name: 'Torrent',
|
||||
seeding_time: 60 * 40,
|
||||
});
|
||||
qbtMock.deleteTorrent.mockResolvedValue({});
|
||||
|
||||
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
|
||||
const result = await processCleanupSeededTorrents({ jobId: 'job-3' });
|
||||
|
||||
expect(result.cleaned).toBe(1);
|
||||
expect(qbtMock.deleteTorrent).toHaveBeenCalledWith('hash-1', true);
|
||||
});
|
||||
|
||||
it('keeps shared torrents and deletes soft-deleted request', async () => {
|
||||
configMock.get.mockResolvedValue(
|
||||
JSON.stringify([{ name: 'IndexerA', seedingTimeMinutes: 10 }])
|
||||
);
|
||||
prismaMock.request.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'req-3',
|
||||
deletedAt: new Date(),
|
||||
downloadHistory: [
|
||||
{
|
||||
selected: true,
|
||||
downloadStatus: 'completed',
|
||||
indexerName: 'IndexerA',
|
||||
torrentHash: 'hash-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: 'req-4', status: 'downloaded' }]);
|
||||
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
name: 'Torrent',
|
||||
seeding_time: 60 * 20,
|
||||
});
|
||||
prismaMock.request.delete.mockResolvedValue({});
|
||||
|
||||
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
|
||||
const result = await processCleanupSeededTorrents({ jobId: 'job-4' });
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-3' } });
|
||||
expect(qbtMock.deleteTorrent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Component: Download Torrent Processor Tests
|
||||
* Documentation: documentation/backend/services/jobs.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
import { createJobQueueMock } from '../helpers/job-queue';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const configMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const jobQueueMock = createJobQueueMock();
|
||||
const qbtMock = vi.hoisted(() => ({ addTorrent: vi.fn() }));
|
||||
const sabMock = vi.hoisted(() => ({ addNZB: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
getQBittorrentService: () => qbtMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
getSABnzbdService: () => sabMock,
|
||||
}));
|
||||
|
||||
describe('processDownloadTorrent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const payload = {
|
||||
requestId: 'req-1',
|
||||
audiobook: { id: 'a1', title: 'Book', author: 'Author' },
|
||||
torrent: {
|
||||
indexer: 'Indexer',
|
||||
title: 'Book - Author',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 10,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:abc',
|
||||
guid: 'guid-1',
|
||||
format: 'M4B',
|
||||
},
|
||||
jobId: 'job-1',
|
||||
};
|
||||
|
||||
it('routes downloads to qBittorrent by default', async () => {
|
||||
configMock.get.mockResolvedValue('qbittorrent');
|
||||
qbtMock.addTorrent.mockResolvedValue('hash-1');
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-1' });
|
||||
|
||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||
const result = await processDownloadTorrent(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(qbtMock.addTorrent).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
'dh-1',
|
||||
'hash-1',
|
||||
'qbittorrent',
|
||||
3
|
||||
);
|
||||
});
|
||||
|
||||
it('routes downloads to SABnzbd when configured', async () => {
|
||||
configMock.get.mockResolvedValue('sabnzbd');
|
||||
sabMock.addNZB.mockResolvedValue('nzb-1');
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.create.mockResolvedValue({ id: 'dh-2' });
|
||||
|
||||
const { processDownloadTorrent } = await import('@/lib/processors/download-torrent.processor');
|
||||
const result = await processDownloadTorrent(payload);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(sabMock.addNZB).toHaveBeenCalled();
|
||||
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
'dh-2',
|
||||
'nzb-1',
|
||||
'sabnzbd',
|
||||
3
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Component: Match Library Processor Tests
|
||||
* Documentation: documentation/phase3/README.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const libraryServiceMock = vi.hoisted(() => ({ searchItems: vi.fn() }));
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
get: vi.fn(),
|
||||
getPlexConfig: vi.fn(),
|
||||
}));
|
||||
const compareTwoStringsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/library', () => ({
|
||||
getLibraryService: async () => libraryServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('string-similarity', () => ({
|
||||
compareTwoStrings: compareTwoStringsMock,
|
||||
}));
|
||||
|
||||
describe('processMatchPlex', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('completes request when no library results are found', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
libraryServiceMock.searchItems.mockResolvedValue([]);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-1',
|
||||
audiobookId: 'ab-1',
|
||||
title: 'Missing Title',
|
||||
author: 'Author',
|
||||
jobId: 'job-1',
|
||||
});
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'req-1' },
|
||||
data: expect.objectContaining({ status: 'completed' }),
|
||||
})
|
||||
);
|
||||
expect(prismaMock.audiobook.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates audiobook and request when a high-score match is found (plex)', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
libraryServiceMock.searchItems.mockResolvedValue([
|
||||
{
|
||||
id: 'item-1',
|
||||
externalId: 'guid-1',
|
||||
title: 'Best Match',
|
||||
author: 'Author',
|
||||
},
|
||||
]);
|
||||
compareTwoStringsMock.mockReturnValue(0.95);
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-2',
|
||||
audiobookId: 'ab-2',
|
||||
title: 'Best Match',
|
||||
author: 'Author',
|
||||
jobId: 'job-2',
|
||||
});
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'ab-2' },
|
||||
data: expect.objectContaining({ plexGuid: 'guid-1' }),
|
||||
})
|
||||
);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'req-2' },
|
||||
data: expect.objectContaining({ status: 'completed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses audiobookshelf IDs when backend mode is audiobookshelf', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
libraryServiceMock.searchItems.mockResolvedValue([
|
||||
{
|
||||
id: 'item-abs',
|
||||
externalId: 'abs-1',
|
||||
title: 'Shelf Match',
|
||||
author: 'Author',
|
||||
},
|
||||
]);
|
||||
compareTwoStringsMock.mockReturnValue(0.9);
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-3',
|
||||
audiobookId: 'ab-3',
|
||||
title: 'Shelf Match',
|
||||
author: 'Author',
|
||||
jobId: 'job-3',
|
||||
});
|
||||
|
||||
expect(result.matched).toBe(true);
|
||||
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ absItemId: 'abs-1' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('completes request without match when score is too low', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: 'plex-lib' });
|
||||
libraryServiceMock.searchItems.mockResolvedValue([
|
||||
{
|
||||
id: 'item-low',
|
||||
externalId: 'guid-low',
|
||||
title: 'Low Match',
|
||||
author: 'Author',
|
||||
},
|
||||
]);
|
||||
compareTwoStringsMock.mockReturnValue(0.1);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-4',
|
||||
audiobookId: 'ab-4',
|
||||
title: 'Low Match',
|
||||
author: 'Author',
|
||||
jobId: 'job-4',
|
||||
});
|
||||
|
||||
expect(result.matched).toBe(false);
|
||||
expect(prismaMock.audiobook.update).not.toHaveBeenCalled();
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'completed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('marks request completed with error when matching fails', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({ libraryId: null });
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMatchPlex } = await import('@/lib/processors/match-plex.processor');
|
||||
const result = await processMatchPlex({
|
||||
requestId: 'req-5',
|
||||
audiobookId: 'ab-5',
|
||||
title: 'Error Title',
|
||||
author: 'Author',
|
||||
jobId: 'job-5',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'completed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Component: Monitor Download Processor Tests
|
||||
* Documentation: documentation/backend/services/jobs.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
import { createJobQueueMock } from '../helpers/job-queue';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = createJobQueueMock();
|
||||
const qbtMock = vi.hoisted(() => ({
|
||||
getTorrent: vi.fn(),
|
||||
getDownloadProgress: vi.fn(),
|
||||
}));
|
||||
const sabMock = vi.hoisted(() => ({
|
||||
getNZB: vi.fn(),
|
||||
}));
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getMany: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
getQBittorrentService: () => qbtMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
getSABnzbdService: () => sabMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
describe('processMonitorDownload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('queues organize job when qBittorrent download completes', async () => {
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
content_path: '/remote/done/Book',
|
||||
save_path: '/remote/done',
|
||||
name: 'Book',
|
||||
});
|
||||
qbtMock.getDownloadProgress.mockReturnValue({
|
||||
percent: 100,
|
||||
state: 'completed',
|
||||
speed: 0,
|
||||
eta: 0,
|
||||
});
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'true',
|
||||
download_client_remote_path: '/remote/done',
|
||||
download_client_local_path: '/downloads',
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.request.findFirst.mockResolvedValue({
|
||||
id: 'req-1',
|
||||
audiobook: { id: 'a1' },
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
const result = await processMonitorDownload({
|
||||
requestId: 'req-1',
|
||||
downloadHistoryId: 'dh-1',
|
||||
downloadClientId: 'hash-1',
|
||||
downloadClient: 'qbittorrent',
|
||||
jobId: 'job-1',
|
||||
});
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
'a1',
|
||||
expect.stringMatching(/downloads[\\/]+Book/)
|
||||
);
|
||||
});
|
||||
|
||||
it('re-schedules monitoring when download is still active', async () => {
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
save_path: '/downloads',
|
||||
name: 'Book',
|
||||
});
|
||||
qbtMock.getDownloadProgress.mockReturnValue({
|
||||
percent: 45,
|
||||
state: 'downloading',
|
||||
speed: 100,
|
||||
eta: 60,
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
const result = await processMonitorDownload({
|
||||
requestId: 'req-2',
|
||||
downloadHistoryId: 'dh-2',
|
||||
downloadClientId: 'hash-2',
|
||||
downloadClient: 'qbittorrent',
|
||||
jobId: 'job-2',
|
||||
});
|
||||
|
||||
expect(result.completed).toBe(false);
|
||||
expect(jobQueueMock.addMonitorJob).toHaveBeenCalledWith(
|
||||
'req-2',
|
||||
'dh-2',
|
||||
'hash-2',
|
||||
'qbittorrent',
|
||||
10
|
||||
);
|
||||
});
|
||||
|
||||
it('marks request failed when download fails', async () => {
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
save_path: '/downloads',
|
||||
name: 'Book',
|
||||
});
|
||||
qbtMock.getDownloadProgress.mockReturnValue({
|
||||
percent: 20,
|
||||
state: 'failed',
|
||||
speed: 0,
|
||||
eta: 0,
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
const result = await processMonitorDownload({
|
||||
requestId: 'req-3',
|
||||
downloadHistoryId: 'dh-3',
|
||||
downloadClientId: 'hash-3',
|
||||
downloadClient: 'qbittorrent',
|
||||
jobId: 'job-3',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles SABnzbd completion and queues organize job', async () => {
|
||||
sabMock.getNZB.mockResolvedValue({
|
||||
nzbId: 'nzb-1',
|
||||
size: 100,
|
||||
progress: 1,
|
||||
status: 'completed',
|
||||
downloadSpeed: 0,
|
||||
timeLeft: 0,
|
||||
downloadPath: '/usenet/complete/Book',
|
||||
});
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
prismaMock.request.findFirst.mockResolvedValue({
|
||||
id: 'req-4',
|
||||
audiobook: { id: 'a4' },
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
const result = await processMonitorDownload({
|
||||
requestId: 'req-4',
|
||||
downloadHistoryId: 'dh-4',
|
||||
downloadClientId: 'nzb-1',
|
||||
downloadClient: 'sabnzbd',
|
||||
jobId: 'job-4',
|
||||
});
|
||||
|
||||
expect(result.completed).toBe(true);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-4',
|
||||
'a4',
|
||||
'/usenet/complete/Book'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mark request failed for transient NZB not found errors', async () => {
|
||||
sabMock.getNZB.mockResolvedValue(null);
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
await expect(processMonitorDownload({
|
||||
requestId: 'req-5',
|
||||
downloadHistoryId: 'dh-5',
|
||||
downloadClientId: 'nzb-missing',
|
||||
downloadClient: 'sabnzbd',
|
||||
jobId: 'job-5',
|
||||
})).rejects.toThrow(/not found/i);
|
||||
|
||||
expect(prismaMock.request.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks request failed when download client is unsupported', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
await expect(processMonitorDownload({
|
||||
requestId: 'req-6',
|
||||
downloadHistoryId: 'dh-6',
|
||||
downloadClientId: 'id-6',
|
||||
downloadClient: 'deluge',
|
||||
jobId: 'job-6',
|
||||
})).rejects.toThrow(/not supported/i);
|
||||
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('marks request failed when SABnzbd completion lacks a download path', async () => {
|
||||
sabMock.getNZB.mockResolvedValue({
|
||||
nzbId: 'nzb-2',
|
||||
size: 100,
|
||||
progress: 1,
|
||||
status: 'completed',
|
||||
downloadSpeed: 0,
|
||||
timeLeft: 0,
|
||||
downloadPath: undefined,
|
||||
});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.downloadHistory.update.mockResolvedValue({});
|
||||
|
||||
const { processMonitorDownload } = await import('@/lib/processors/monitor-download.processor');
|
||||
await expect(processMonitorDownload({
|
||||
requestId: 'req-7',
|
||||
downloadHistoryId: 'dh-7',
|
||||
downloadClientId: 'nzb-2',
|
||||
downloadClient: 'sabnzbd',
|
||||
jobId: 'job-7',
|
||||
})).rejects.toThrow(/Download path not available/i);
|
||||
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Component: Monitor RSS Feeds Processor Tests
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
import { createJobQueueMock } from '../helpers/job-queue';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const configMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const jobQueueMock = createJobQueueMock();
|
||||
const prowlarrMock = vi.hoisted(() => ({ getAllRssFeeds: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
||||
getProwlarrService: () => prowlarrMock,
|
||||
}));
|
||||
|
||||
describe('processMonitorRssFeeds', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('matches RSS items and queues search jobs', async () => {
|
||||
configMock.get.mockResolvedValue(
|
||||
JSON.stringify([{ id: 1, name: 'Indexer', rssEnabled: true }])
|
||||
);
|
||||
|
||||
prowlarrMock.getAllRssFeeds.mockResolvedValue([
|
||||
{ title: 'Great Book - Author Name' },
|
||||
]);
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-1',
|
||||
audiobook: { id: 'a1', title: 'Great Book', author: 'Author Name', audibleAsin: 'ASIN1' },
|
||||
},
|
||||
]);
|
||||
|
||||
const { processMonitorRssFeeds } = await import('@/lib/processors/monitor-rss-feeds.processor');
|
||||
const result = await processMonitorRssFeeds({ jobId: 'job-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
expect.objectContaining({ title: 'Great Book', author: 'Author Name' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Component: Organize Files Processor Tests
|
||||
* Documentation: documentation/phase3/file-organization.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const organizerMock = vi.hoisted(() => ({ organize: vi.fn() }));
|
||||
const libraryServiceMock = vi.hoisted(() => ({ triggerLibraryScan: vi.fn() }));
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/file-organizer', () => ({
|
||||
getFileOrganizer: () => organizerMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/library', () => ({
|
||||
getLibraryService: () => libraryServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
describe('processOrganizeFiles', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('organizes files and triggers filesystem scan when enabled', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.findUnique.mockResolvedValue({
|
||||
id: 'a1',
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
coverArtUrl: null,
|
||||
audibleAsin: 'ASIN1',
|
||||
});
|
||||
organizerMock.organize.mockResolvedValue({
|
||||
success: true,
|
||||
targetPath: '/media/Author/Book',
|
||||
filesMovedCount: 1,
|
||||
errors: [],
|
||||
audioFiles: ['/media/Author/Book/Book.m4b'],
|
||||
});
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'plex.trigger_scan_after_import') return 'true';
|
||||
if (key === 'plex_audiobook_library_id') return 'lib-1';
|
||||
return null;
|
||||
});
|
||||
|
||||
const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor');
|
||||
const result = await processOrganizeFiles({
|
||||
requestId: 'req-1',
|
||||
audiobookId: 'a1',
|
||||
downloadPath: '/downloads/book',
|
||||
jobId: 'job-1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(libraryServiceMock.triggerLibraryScan).toHaveBeenCalledWith('lib-1');
|
||||
});
|
||||
|
||||
it('queues retry when a retryable error occurs', async () => {
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.audiobook.findUnique.mockResolvedValue({
|
||||
id: 'a2',
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
coverArtUrl: null,
|
||||
audibleAsin: 'ASIN2',
|
||||
});
|
||||
organizerMock.organize.mockResolvedValue({
|
||||
success: false,
|
||||
targetPath: '',
|
||||
filesMovedCount: 0,
|
||||
errors: ['No audiobook files found in download'],
|
||||
audioFiles: [],
|
||||
});
|
||||
prismaMock.request.findFirst.mockResolvedValue({
|
||||
importAttempts: 0,
|
||||
maxImportRetries: 3,
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor');
|
||||
const result = await processOrganizeFiles({
|
||||
requestId: 'req-2',
|
||||
audiobookId: 'a2',
|
||||
downloadPath: '/downloads/book',
|
||||
jobId: 'job-2',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'awaiting_import' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Component: Recently Added Processor Tests
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const libraryServiceMock = vi.hoisted(() => ({
|
||||
getRecentlyAdded: vi.fn(),
|
||||
}));
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
getMany: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/library', () => ({
|
||||
getLibraryService: async () => libraryServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
findPlexMatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/audiobookshelf/api', () => ({
|
||||
triggerABSItemMatch: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('processPlexRecentlyAddedCheck', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('skips when Plex configuration is missing', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getMany.mockResolvedValue({
|
||||
plex_url: '',
|
||||
plex_token: '',
|
||||
plex_audiobook_library_id: '',
|
||||
});
|
||||
|
||||
const { processPlexRecentlyAddedCheck } = await import('@/lib/processors/plex-recently-added.processor');
|
||||
const result = await processPlexRecentlyAddedCheck({ jobId: 'job-1' });
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(prismaMock.plexLibrary.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates and updates recently added library items', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getMany.mockResolvedValue({
|
||||
plex_url: 'http://plex',
|
||||
plex_token: 'token',
|
||||
plex_audiobook_library_id: 'lib-1',
|
||||
});
|
||||
configMock.get.mockResolvedValue('lib-1');
|
||||
libraryServiceMock.getRecentlyAdded.mockResolvedValue([
|
||||
{
|
||||
id: 'rating-1',
|
||||
externalId: 'guid-1',
|
||||
title: 'New Item',
|
||||
author: 'Author A',
|
||||
addedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'rating-2',
|
||||
externalId: 'guid-2',
|
||||
title: 'Existing Item',
|
||||
author: 'Author B',
|
||||
addedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.plexLibrary.findUnique.mockImplementation(async (query: any) => {
|
||||
if (query.where.plexGuid === 'guid-2') {
|
||||
return { id: 'existing-id', plexGuid: 'guid-2', author: 'Author B' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
prismaMock.plexLibrary.create.mockResolvedValue({});
|
||||
prismaMock.plexLibrary.update.mockResolvedValue({});
|
||||
prismaMock.request.findMany.mockResolvedValue([]);
|
||||
|
||||
const { processPlexRecentlyAddedCheck } = await import('@/lib/processors/plex-recently-added.processor');
|
||||
const result = await processPlexRecentlyAddedCheck({ jobId: 'job-2' });
|
||||
|
||||
expect(result.newCount).toBe(1);
|
||||
expect(result.updatedCount).toBe(1);
|
||||
expect(prismaMock.plexLibrary.create).toHaveBeenCalled();
|
||||
expect(prismaMock.plexLibrary.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches requests and triggers ABS metadata match for audiobookshelf', async () => {
|
||||
const matcher = await import('@/lib/utils/audiobook-matcher');
|
||||
const absApi = await import('@/lib/services/audiobookshelf/api');
|
||||
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.getMany.mockResolvedValue({
|
||||
'audiobookshelf.server_url': 'http://abs',
|
||||
'audiobookshelf.api_token': 'token',
|
||||
'audiobookshelf.library_id': 'abs-lib',
|
||||
});
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
libraryServiceMock.getRecentlyAdded.mockResolvedValue([
|
||||
{
|
||||
id: 'abs-1',
|
||||
externalId: 'abs-item-1',
|
||||
title: 'New ABS Item',
|
||||
author: 'Author A',
|
||||
addedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
prismaMock.plexLibrary.findUnique.mockResolvedValue(null);
|
||||
prismaMock.plexLibrary.create.mockResolvedValue({});
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-1',
|
||||
status: 'downloaded',
|
||||
audiobook: {
|
||||
id: 'ab-1',
|
||||
title: 'Match Me',
|
||||
author: 'Author A',
|
||||
narrator: 'Narrator A',
|
||||
audibleAsin: 'ASIN-ABS',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
(matcher.findPlexMatch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
plexGuid: 'abs-item-1',
|
||||
plexRatingKey: 'rating-abs',
|
||||
title: 'Match Me',
|
||||
author: 'Author A',
|
||||
});
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processPlexRecentlyAddedCheck } = await import('@/lib/processors/plex-recently-added.processor');
|
||||
const result = await processPlexRecentlyAddedCheck({ jobId: 'job-3' });
|
||||
|
||||
expect(result.matchedDownloads).toBe(1);
|
||||
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ absItemId: 'abs-item-1' }),
|
||||
})
|
||||
);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'available' }),
|
||||
})
|
||||
);
|
||||
expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-item-1', 'ASIN-ABS');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Component: Retry Failed Imports Processor Tests
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
import { createJobQueueMock } from '../helpers/job-queue';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = createJobQueueMock();
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getMany: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const qbtMock = vi.hoisted(() => ({ getTorrent: vi.fn() }));
|
||||
const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/qbittorrent.service', () => ({
|
||||
getQBittorrentService: () => qbtMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/sabnzbd.service', () => ({
|
||||
getSABnzbdService: () => sabnzbdMock,
|
||||
}));
|
||||
|
||||
describe('processRetryFailedImports', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('queues organize jobs using download client paths', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-1',
|
||||
audiobook: { id: 'a1', title: 'Book' },
|
||||
downloadHistory: [{ torrentHash: 'hash-1', torrentName: 'Book' }],
|
||||
},
|
||||
]);
|
||||
|
||||
qbtMock.getTorrent.mockResolvedValue({
|
||||
save_path: '/downloads',
|
||||
name: 'Book',
|
||||
});
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
'a1',
|
||||
'/downloads/Book'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns early when no requests await import', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.triggered).toBe(0);
|
||||
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips requests missing download history', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-2',
|
||||
audiobook: { id: 'a2', title: 'Book' },
|
||||
downloadHistory: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-2' });
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(result.triggered).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to configured download dir when qBittorrent lookup fails', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'true',
|
||||
download_client_remote_path: '/remote',
|
||||
download_client_local_path: '/downloads',
|
||||
});
|
||||
configMock.get.mockResolvedValue('/remote');
|
||||
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-3',
|
||||
audiobook: { id: 'a3', title: 'Book' },
|
||||
downloadHistory: [{ torrentHash: 'hash-3', torrentName: 'Book' }],
|
||||
},
|
||||
]);
|
||||
|
||||
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-3' });
|
||||
|
||||
expect(result.triggered).toBe(1);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-3',
|
||||
'a3',
|
||||
path.join('/downloads', 'Book')
|
||||
);
|
||||
});
|
||||
|
||||
it('uses SABnzbd download path when available', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'true',
|
||||
download_client_remote_path: '/remote/nzb',
|
||||
download_client_local_path: '/downloads',
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-4',
|
||||
audiobook: { id: 'a4', title: 'Book' },
|
||||
downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'Book' }],
|
||||
},
|
||||
]);
|
||||
|
||||
sabnzbdMock.getNZB.mockResolvedValue({ downloadPath: '/remote/nzb/Book' });
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-4' });
|
||||
|
||||
expect(result.triggered).toBe(1);
|
||||
expect(jobQueueMock.addOrganizeJob).toHaveBeenCalledWith(
|
||||
'req-4',
|
||||
'a4',
|
||||
path.join('/downloads', 'Book')
|
||||
);
|
||||
});
|
||||
|
||||
it('skips SABnzbd retries when download dir is missing', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
configMock.get.mockResolvedValue(null);
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-5',
|
||||
audiobook: { id: 'a5', title: 'Book' },
|
||||
downloadHistory: [{ nzbId: 'nzb-2', torrentName: 'Book' }],
|
||||
},
|
||||
]);
|
||||
|
||||
sabnzbdMock.getNZB.mockResolvedValue(null);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-5' });
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips requests with no client identifiers or names', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-6',
|
||||
audiobook: { id: 'a6', title: 'Book' },
|
||||
downloadHistory: [{}],
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-6' });
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tracks skipped requests when organize job fails', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-7',
|
||||
audiobook: { id: 'a7', title: 'Book' },
|
||||
downloadHistory: [{ torrentHash: 'hash-7', torrentName: 'Book' }],
|
||||
},
|
||||
]);
|
||||
qbtMock.getTorrent.mockResolvedValue({ save_path: '/downloads', name: 'Book' });
|
||||
jobQueueMock.addOrganizeJob.mockRejectedValue(new Error('queue down'));
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-7' });
|
||||
|
||||
expect(result.triggered).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('skips qBittorrent fallbacks when torrent name is missing', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-8',
|
||||
audiobook: { id: 'a8', title: 'Book' },
|
||||
downloadHistory: [{ torrentHash: 'hash-8' }],
|
||||
},
|
||||
]);
|
||||
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-8' });
|
||||
|
||||
expect(result.triggered).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(jobQueueMock.addOrganizeJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips qBittorrent fallbacks when download_dir is not configured', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
configMock.get.mockResolvedValue(null);
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-9',
|
||||
audiobook: { id: 'a9', title: 'Book' },
|
||||
downloadHistory: [{ torrentHash: 'hash-9', torrentName: 'Book' }],
|
||||
},
|
||||
]);
|
||||
qbtMock.getTorrent.mockRejectedValue(new Error('not found'));
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-9' });
|
||||
|
||||
expect(result.triggered).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('skips SABnzbd retries when the client throws', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-10',
|
||||
audiobook: { id: 'a10', title: 'Book' },
|
||||
downloadHistory: [{ nzbId: 'nzb-10', torrentName: 'Book' }],
|
||||
},
|
||||
]);
|
||||
|
||||
sabnzbdMock.getNZB.mockRejectedValue(new Error('sab down'));
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-10' });
|
||||
|
||||
expect(result.triggered).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('skips requests without download_dir when no client identifiers exist', async () => {
|
||||
configMock.getMany.mockResolvedValue({
|
||||
download_client_remote_path_mapping_enabled: 'false',
|
||||
download_client_remote_path: '',
|
||||
download_client_local_path: '',
|
||||
});
|
||||
configMock.get.mockResolvedValue(null);
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-11',
|
||||
audiobook: { id: 'a11', title: 'Book' },
|
||||
downloadHistory: [{ torrentName: 'Book' }],
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryFailedImports } = await import('@/lib/processors/retry-failed-imports.processor');
|
||||
const result = await processRetryFailedImports({ jobId: 'job-11' });
|
||||
|
||||
expect(result.triggered).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Component: Retry Missing Torrents Processor Tests
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
import { createJobQueueMock } from '../helpers/job-queue';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const jobQueueMock = createJobQueueMock();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
describe('processRetryMissingTorrents', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('queues search jobs for awaiting_search requests', async () => {
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-1',
|
||||
audiobook: { id: 'a1', title: 'Book', author: 'Author', audibleAsin: 'ASIN1' },
|
||||
},
|
||||
]);
|
||||
|
||||
const { processRetryMissingTorrents } = await import('@/lib/processors/retry-missing-torrents.processor');
|
||||
const result = await processRetryMissingTorrents({ jobId: 'job-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
expect.objectContaining({ id: 'a1', title: 'Book', author: 'Author' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Component: Library Scan Processor Tests
|
||||
* Documentation: documentation/backend/services/jobs.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const libraryServiceMock = vi.hoisted(() => ({ getLibraryItems: vi.fn() }));
|
||||
const configMock = vi.hoisted(() => ({
|
||||
getBackendMode: vi.fn(),
|
||||
getPlexConfig: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
findPlexMatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/audiobookshelf/api', () => ({
|
||||
triggerABSItemMatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/library', () => ({
|
||||
getLibraryService: () => libraryServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
describe('processScanPlex', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates and updates library items, matches requests', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
libraryId: 'lib-1',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
|
||||
libraryServiceMock.getLibraryItems.mockResolvedValue([
|
||||
{
|
||||
id: 'rating-1',
|
||||
externalId: 'guid-1',
|
||||
title: 'New Book',
|
||||
author: 'Author',
|
||||
addedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'rating-2',
|
||||
externalId: 'guid-2',
|
||||
title: 'Existing Book',
|
||||
author: 'Author',
|
||||
addedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.plexLibrary.findFirst.mockImplementation(async (query: any) => {
|
||||
if (query.where.plexGuid === 'guid-2') {
|
||||
return { id: 'existing-id', plexGuid: 'guid-2' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
prismaMock.plexLibrary.create.mockResolvedValue({ id: 'new-id', plexGuid: 'guid-1' });
|
||||
prismaMock.plexLibrary.update.mockResolvedValue({});
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
prismaMock.audiobook.findMany.mockResolvedValue([]);
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-1',
|
||||
status: 'downloaded',
|
||||
audiobook: {
|
||||
id: 'a1',
|
||||
title: 'New Book',
|
||||
author: 'Author',
|
||||
narrator: null,
|
||||
audibleAsin: 'ASIN1',
|
||||
},
|
||||
},
|
||||
]);
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const matcher = await import('@/lib/utils/audiobook-matcher');
|
||||
vi.spyOn(matcher, 'findPlexMatch').mockResolvedValue({
|
||||
plexGuid: 'guid-1',
|
||||
plexRatingKey: 'rating-1',
|
||||
title: 'New Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
|
||||
const result = await processScanPlex({ jobId: 'job-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prismaMock.plexLibrary.create).toHaveBeenCalled();
|
||||
expect(prismaMock.plexLibrary.update).toHaveBeenCalled();
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'available' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when audiobookshelf library is not configured', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue(null);
|
||||
|
||||
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
|
||||
|
||||
await expect(processScanPlex({ jobId: 'job-2' })).rejects.toThrow(
|
||||
'Audiobookshelf library not configured'
|
||||
);
|
||||
expect(libraryServiceMock.getLibraryItems).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes stale items and resets linked audiobooks and requests', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('plex');
|
||||
configMock.getPlexConfig.mockResolvedValue({
|
||||
serverUrl: 'http://plex',
|
||||
authToken: 'token',
|
||||
libraryId: 'lib-1',
|
||||
machineIdentifier: 'machine',
|
||||
});
|
||||
|
||||
libraryServiceMock.getLibraryItems.mockResolvedValue([
|
||||
{
|
||||
id: 'rating-1',
|
||||
externalId: 'guid-1',
|
||||
title: 'Current Book',
|
||||
author: 'Author',
|
||||
addedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.plexLibrary.findFirst.mockResolvedValue(null);
|
||||
prismaMock.plexLibrary.create.mockResolvedValue({ id: 'new-id', plexGuid: 'guid-1' });
|
||||
prismaMock.plexLibrary.findMany
|
||||
.mockResolvedValueOnce([{ id: 'stale-1', plexGuid: 'stale-guid', title: 'Stale Book' }])
|
||||
.mockResolvedValueOnce([{ plexGuid: 'guid-1' }]);
|
||||
prismaMock.plexLibrary.delete.mockResolvedValue({});
|
||||
prismaMock.audiobook.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'ab-1',
|
||||
title: 'Stale Book',
|
||||
requests: [{ id: 'req-1', status: 'available' }],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'ab-valid',
|
||||
title: 'Valid Book',
|
||||
plexGuid: 'guid-1',
|
||||
absItemId: null,
|
||||
requests: [],
|
||||
},
|
||||
{
|
||||
id: 'ab-orphan',
|
||||
title: 'Orphaned Book',
|
||||
plexGuid: null,
|
||||
absItemId: 'missing-guid',
|
||||
requests: [{ id: 'req-2', status: 'available' }],
|
||||
},
|
||||
]);
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
prismaMock.request.findMany.mockResolvedValue([]);
|
||||
|
||||
const matcher = await import('@/lib/utils/audiobook-matcher');
|
||||
(matcher.findPlexMatch as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
|
||||
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
|
||||
const result = await processScanPlex({ jobId: 'job-3' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prismaMock.plexLibrary.delete).toHaveBeenCalledWith({ where: { id: 'stale-1' } });
|
||||
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'ab-orphan' },
|
||||
data: expect.objectContaining({ plexGuid: null, absItemId: null }),
|
||||
})
|
||||
);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'downloaded' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('matches audiobookshelf requests and triggers metadata match', async () => {
|
||||
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
||||
configMock.get.mockResolvedValue('abs-lib');
|
||||
|
||||
libraryServiceMock.getLibraryItems.mockResolvedValue([]);
|
||||
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
prismaMock.audiobook.findMany.mockResolvedValue([]);
|
||||
prismaMock.request.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'req-abs',
|
||||
status: 'downloaded',
|
||||
audiobook: {
|
||||
id: 'abs-audio',
|
||||
title: 'ABS Title',
|
||||
author: 'ABS Author',
|
||||
narrator: 'Narrator',
|
||||
audibleAsin: 'ASIN123',
|
||||
},
|
||||
},
|
||||
]);
|
||||
prismaMock.audiobook.update.mockResolvedValue({});
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const matcher = await import('@/lib/utils/audiobook-matcher');
|
||||
(matcher.findPlexMatch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
plexGuid: 'abs-item-1',
|
||||
plexRatingKey: 'rating-abs',
|
||||
title: 'ABS Title',
|
||||
author: 'ABS Author',
|
||||
});
|
||||
|
||||
const absApi = await import('@/lib/services/audiobookshelf/api');
|
||||
|
||||
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
|
||||
const result = await processScanPlex({ jobId: 'job-4' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ absItemId: 'abs-item-1' }),
|
||||
})
|
||||
);
|
||||
expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-item-1', 'ASIN123');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Component: Search Indexers Processor Tests
|
||||
* Documentation: documentation/backend/services/jobs.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
import { createJobQueueMock } from '../helpers/job-queue';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
const configMock = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const jobQueueMock = createJobQueueMock();
|
||||
const prowlarrMock = vi.hoisted(() => ({ search: vi.fn() }));
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => configMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => jobQueueMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/prowlarr.service', () => ({
|
||||
getProwlarrService: () => prowlarrMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
getAudibleService: () => ({ getRuntime: vi.fn().mockResolvedValue(null) }),
|
||||
}));
|
||||
|
||||
describe('processSearchIndexers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('marks request awaiting_search when no results found', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', priority: 10, categories: [3030] }]);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
prowlarrMock.search.mockResolvedValue([]);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor');
|
||||
const result = await processSearchIndexers({
|
||||
requestId: 'req-1',
|
||||
audiobook: { id: 'a1', title: 'Book', author: 'Author' },
|
||||
jobId: 'job-1',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'awaiting_search' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('queues download job when results are ranked', async () => {
|
||||
configMock.get.mockImplementation(async (key: string) => {
|
||||
if (key === 'prowlarr_indexers') {
|
||||
return JSON.stringify([{ id: 1, name: 'Indexer', priority: 10, categories: [3030] }]);
|
||||
}
|
||||
if (key === 'indexer_flag_config') {
|
||||
return JSON.stringify([]);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
prowlarrMock.search.mockResolvedValue([
|
||||
{
|
||||
indexer: 'Indexer',
|
||||
indexerId: 1,
|
||||
title: 'Book - Author',
|
||||
size: 50 * 1024 * 1024,
|
||||
seeders: 10,
|
||||
publishDate: new Date(),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:abc',
|
||||
guid: 'guid-1',
|
||||
format: 'M4B',
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor');
|
||||
const result = await processSearchIndexers({
|
||||
requestId: 'req-2',
|
||||
audiobook: { id: 'a2', title: 'Book', author: 'Author' },
|
||||
jobId: 'job-2',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(jobQueueMock.addDownloadJob).toHaveBeenCalledWith(
|
||||
'req-2',
|
||||
{ id: 'a2', title: 'Book', author: 'Author' },
|
||||
expect.objectContaining({ title: 'Book - Author' })
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when no indexers are configured', async () => {
|
||||
configMock.get.mockResolvedValue(null);
|
||||
prismaMock.request.update.mockResolvedValue({});
|
||||
|
||||
const { processSearchIndexers } = await import('@/lib/processors/search-indexers.processor');
|
||||
await expect(
|
||||
processSearchIndexers({
|
||||
requestId: 'req-3',
|
||||
audiobook: { id: 'a3', title: 'Book', author: 'Author' },
|
||||
jobId: 'job-3',
|
||||
})
|
||||
).rejects.toThrow('No indexers configured');
|
||||
|
||||
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'failed' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Component: Test Setup
|
||||
* Documentation: documentation/README.md
|
||||
*/
|
||||
|
||||
import { beforeAll, afterAll, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TZ = 'UTC';
|
||||
|
||||
if (!globalThis.fetch) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).fetch = () => {
|
||||
throw new Error('fetch was called without a mock in tests');
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Component: API Utility Functions Tests
|
||||
* Documentation: documentation/frontend/utilities.md
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const jwtState = vi.hoisted(() => ({
|
||||
isTokenExpired: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/jwt-client', () => ({
|
||||
isTokenExpired: jwtState.isTokenExpired,
|
||||
}));
|
||||
|
||||
describe('api utilities', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
const storage = new Map<string, string>();
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
const localStorageMock = {
|
||||
getItem: (key: string) => (storage.has(key) ? storage.get(key)! : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
storage.clear();
|
||||
},
|
||||
};
|
||||
|
||||
const createResponse = (status: number, body: unknown, ok = status >= 200 && status < 300) => ({
|
||||
ok,
|
||||
status,
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
storage.clear();
|
||||
fetchMock = vi.fn();
|
||||
|
||||
(globalThis as any).localStorage = localStorageMock;
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
|
||||
jwtState.isTokenExpired.mockReset();
|
||||
jwtState.isTokenExpired.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.window = originalWindow;
|
||||
});
|
||||
|
||||
it('adds authorization headers when access token exists', async () => {
|
||||
const { fetchWithAuth } = await import('@/lib/utils/api');
|
||||
|
||||
localStorageMock.setItem('accessToken', 'token-1');
|
||||
fetchMock.mockResolvedValue(createResponse(200, {}));
|
||||
|
||||
await fetchWithAuth('/api/data', { headers: { 'X-Test': '1' } });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(init.headers).toEqual({
|
||||
'X-Test': '1',
|
||||
'Authorization': 'Bearer token-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes tokens on 401 and retries the request', async () => {
|
||||
const { fetchWithAuth } = await import('@/lib/utils/api');
|
||||
|
||||
localStorageMock.setItem('accessToken', 'token-old');
|
||||
localStorageMock.setItem('refreshToken', 'refresh-1');
|
||||
|
||||
let call = 0;
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url === '/api/auth/refresh') {
|
||||
return createResponse(200, { accessToken: 'token-new' }, true);
|
||||
}
|
||||
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
return createResponse(401, {}, false);
|
||||
}
|
||||
return createResponse(200, { ok: true }, true);
|
||||
});
|
||||
|
||||
const response = await fetchWithAuth('/api/data');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(localStorageMock.getItem('accessToken')).toBe('token-new');
|
||||
|
||||
const retryCall = fetchMock.mock.calls.find((entry: any[]) => entry[0] === '/api/data' && entry[1]?.headers?.Authorization === 'Bearer token-new');
|
||||
expect(retryCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('logs out when refresh token is expired', async () => {
|
||||
const { fetchWithAuth } = await import('@/lib/utils/api');
|
||||
|
||||
jwtState.isTokenExpired.mockReturnValue(true);
|
||||
localStorageMock.setItem('accessToken', 'token-old');
|
||||
localStorageMock.setItem('refreshToken', 'refresh-1');
|
||||
localStorageMock.setItem('user', 'user');
|
||||
|
||||
globalThis.window = { location: { pathname: '/requests', href: '' } } as any;
|
||||
|
||||
fetchMock.mockResolvedValue(createResponse(401, {}, false));
|
||||
|
||||
await fetchWithAuth('/api/data');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(localStorageMock.getItem('accessToken')).toBeNull();
|
||||
expect(localStorageMock.getItem('refreshToken')).toBeNull();
|
||||
expect(globalThis.window.location.href).toBe('/login?redirect=%2Frequests');
|
||||
});
|
||||
|
||||
it('logs out when refreshed token still yields 401', async () => {
|
||||
const { fetchWithAuth } = await import('@/lib/utils/api');
|
||||
|
||||
localStorageMock.setItem('accessToken', 'token-old');
|
||||
localStorageMock.setItem('refreshToken', 'refresh-1');
|
||||
localStorageMock.setItem('user', 'user');
|
||||
|
||||
globalThis.window = { location: { pathname: '/requests', href: '' } } as any;
|
||||
|
||||
let call = 0;
|
||||
fetchMock.mockImplementation(async (url: string) => {
|
||||
if (url === '/api/auth/refresh') {
|
||||
return createResponse(200, { accessToken: 'token-new' }, true);
|
||||
}
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
return createResponse(401, {}, false);
|
||||
}
|
||||
return createResponse(401, {}, false);
|
||||
});
|
||||
|
||||
await fetchWithAuth('/api/data');
|
||||
|
||||
expect(localStorageMock.getItem('accessToken')).toBeNull();
|
||||
expect(localStorageMock.getItem('refreshToken')).toBeNull();
|
||||
expect(globalThis.window.location.href).toBe('/login?redirect=%2Frequests');
|
||||
});
|
||||
|
||||
it('fetches JSON data successfully', async () => {
|
||||
const { fetchJSON } = await import('@/lib/utils/api');
|
||||
|
||||
fetchMock.mockResolvedValue(createResponse(200, { ok: true }, true));
|
||||
|
||||
const result = await fetchJSON('/api/data');
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('throws a useful error when JSON request fails', async () => {
|
||||
const { fetchJSON } = await import('@/lib/utils/api');
|
||||
|
||||
fetchMock.mockResolvedValue(createResponse(500, { message: 'bad' }, false));
|
||||
|
||||
await expect(fetchJSON('/api/data')).rejects.toThrow('bad');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Component: Audiobook Matcher Tests
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
describe('audiobook-matcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns ASIN exact match from dedicated field', async () => {
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
plexGuid: 'guid-1',
|
||||
plexRatingKey: 'rating-1',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
asin: 'B00TEST123',
|
||||
isbn: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const { findPlexMatch } = await import('@/lib/utils/audiobook-matcher');
|
||||
const match = await findPlexMatch({
|
||||
asin: 'B00TEST123',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
});
|
||||
|
||||
expect(match?.plexGuid).toBe('guid-1');
|
||||
});
|
||||
|
||||
it('rejects candidates with mismatched ASINs in plexGuid', async () => {
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
plexGuid: 'com.plexapp.agents.audible://B00WRONG999',
|
||||
plexRatingKey: null,
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
asin: null,
|
||||
isbn: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const { findPlexMatch } = await import('@/lib/utils/audiobook-matcher');
|
||||
const match = await findPlexMatch({
|
||||
asin: 'B00RIGHT123',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it('uses narrator matching when author match is weak', async () => {
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{
|
||||
plexGuid: 'guid-narrator',
|
||||
plexRatingKey: null,
|
||||
title: 'Great Book',
|
||||
author: 'Jane Narrator',
|
||||
asin: null,
|
||||
isbn: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const { findPlexMatch } = await import('@/lib/utils/audiobook-matcher');
|
||||
const match = await findPlexMatch({
|
||||
asin: 'B00TEST999',
|
||||
title: 'Great Book',
|
||||
author: 'Different Author',
|
||||
narrator: 'Jane Narrator',
|
||||
});
|
||||
|
||||
expect(match?.plexGuid).toBe('guid-narrator');
|
||||
});
|
||||
|
||||
it('matches library items by ASIN, ISBN, then fuzzy match', async () => {
|
||||
const items = [
|
||||
{ id: '1', externalId: 'g1', title: 'Alpha', author: 'Author A', asin: 'ASIN1' },
|
||||
{ id: '2', externalId: 'g2', title: 'Beta', author: 'Author B', isbn: '978-1-23456-789-7' },
|
||||
{ id: '3', externalId: 'g3', title: 'Gamma Book', author: 'Author C' },
|
||||
];
|
||||
|
||||
const { matchAudiobook } = await import('@/lib/utils/audiobook-matcher');
|
||||
const asinMatch = matchAudiobook({ title: 'x', author: 'y', asin: 'ASIN1' }, items);
|
||||
expect(asinMatch?.externalId).toBe('g1');
|
||||
|
||||
const isbnMatch = matchAudiobook({ title: 'x', author: 'y', isbn: '9781234567897' }, items);
|
||||
expect(isbnMatch?.externalId).toBe('g2');
|
||||
|
||||
const fuzzyMatch = matchAudiobook({ title: 'Gamma Book', author: 'Author C' }, items);
|
||||
expect(fuzzyMatch?.externalId).toBe('g3');
|
||||
});
|
||||
|
||||
it('enriches audiobooks with availability and request status', async () => {
|
||||
prismaMock.plexLibrary.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
plexGuid: 'guid-1',
|
||||
plexRatingKey: null,
|
||||
title: 'Book One',
|
||||
author: 'Author One',
|
||||
asin: 'ASIN1',
|
||||
isbn: null,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
prismaMock.audiobook.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'a1',
|
||||
audibleAsin: 'ASIN1',
|
||||
requests: [
|
||||
{
|
||||
id: 'r1',
|
||||
status: 'downloading',
|
||||
userId: 'other-user',
|
||||
user: { plexUsername: 'OtherUser' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const { enrichAudiobooksWithMatches } = await import('@/lib/utils/audiobook-matcher');
|
||||
const results = await enrichAudiobooksWithMatches(
|
||||
[
|
||||
{ asin: 'ASIN1', title: 'Book One', author: 'Author One' },
|
||||
{ asin: 'ASIN2', title: 'Book Two', author: 'Author Two' },
|
||||
],
|
||||
'current-user'
|
||||
);
|
||||
|
||||
expect(results[0].isAvailable).toBe(true);
|
||||
expect(results[0].isRequested).toBe(true);
|
||||
expect(results[0].requestedByUsername).toBe('OtherUser');
|
||||
|
||||
expect(results[1].isAvailable).toBe(false);
|
||||
expect(results[1].isRequested).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* Component: Chapter Merger Utility Tests
|
||||
* Documentation: documentation/features/chapter-merging.md
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
analyzeChapterFiles,
|
||||
checkDiskSpace,
|
||||
detectChapterFiles,
|
||||
estimateOutputSize,
|
||||
formatDuration,
|
||||
mergeChapters,
|
||||
probeAudioFile,
|
||||
} from '@/lib/utils/chapter-merger';
|
||||
|
||||
const execState = vi.hoisted(() => {
|
||||
const state = {
|
||||
handler: null as null | ((command: string) => { stdout?: string; error?: Error }),
|
||||
};
|
||||
const custom = Symbol.for('nodejs.util.promisify.custom');
|
||||
const exec = vi.fn();
|
||||
(exec as any)[custom] = (command: string) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const result = state.handler ? state.handler(command) : { stdout: '' };
|
||||
if (result.error) {
|
||||
reject(result.error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: result.stdout ?? '', stderr: '' });
|
||||
});
|
||||
return { exec, state };
|
||||
});
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
constants: { R_OK: 4 },
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
exec: execState.exec,
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
}));
|
||||
|
||||
function createSpawnProcess(exitCode = 0, stderrData = '') {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
stderr: EventEmitter;
|
||||
kill: () => void;
|
||||
};
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.kill = vi.fn();
|
||||
|
||||
setImmediate(() => {
|
||||
if (stderrData) {
|
||||
proc.stderr.emit('data', Buffer.from(stderrData));
|
||||
}
|
||||
proc.emit('close', exitCode);
|
||||
});
|
||||
|
||||
return proc;
|
||||
}
|
||||
|
||||
function mockExecImplementation(handlers: (command: string) => { stdout?: string; error?: Error }) {
|
||||
execState.state.handler = handlers;
|
||||
}
|
||||
|
||||
describe('chapter merger', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
execState.state.handler = null;
|
||||
});
|
||||
|
||||
it('detects when chapter merging should be skipped', async () => {
|
||||
await expect(detectChapterFiles(['one.mp3', 'two.mp3'])).resolves.toBe(false);
|
||||
await expect(detectChapterFiles(['one.mp3', 'two.m4b', 'three.mp3'])).resolves.toBe(false);
|
||||
await expect(detectChapterFiles(['one.wav', 'two.wav', 'three.wav'])).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('detects eligible chapter files', async () => {
|
||||
await expect(detectChapterFiles(['one.mp3', 'two.mp3', 'three.mp3'])).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('orders chapters by metadata when track numbers are sequential', async () => {
|
||||
const files = ['/tmp/b.mp3', '/tmp/a.mp3', '/tmp/c.mp3'];
|
||||
const probeMap: Record<string, { duration: number; bitrate: number; track: number }> = {
|
||||
'/tmp/b.mp3': { duration: 60, bitrate: 128000, track: 1 },
|
||||
'/tmp/a.mp3': { duration: 60, bitrate: 128000, track: 2 },
|
||||
'/tmp/c.mp3': { duration: 60, bitrate: 128000, track: 3 },
|
||||
};
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
const matches = command.match(/"([^"]+)"/g) ?? [];
|
||||
const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : '';
|
||||
const probe = probeMap[filePath];
|
||||
if (!probe) {
|
||||
throw new Error(`Missing probe data for ${filePath}`);
|
||||
}
|
||||
const payload = {
|
||||
format: {
|
||||
duration: String(probe.duration),
|
||||
bit_rate: String(probe.bitrate),
|
||||
tags: { track: String(probe.track) },
|
||||
},
|
||||
};
|
||||
return { stdout: JSON.stringify(payload) };
|
||||
});
|
||||
|
||||
const ordered = await analyzeChapterFiles(files);
|
||||
|
||||
expect(ordered.map((file) => path.basename(file.path))).toEqual(['b.mp3', 'a.mp3', 'c.mp3']);
|
||||
expect(ordered[0].chapterTitle).toBe('Chapter 1');
|
||||
});
|
||||
|
||||
it('orders chapters by filename when track numbers are missing', async () => {
|
||||
const files = ['/tmp/02 - Middle.mp3', '/tmp/01 - Start.mp3', '/tmp/03 - End.mp3'];
|
||||
const probeMap: Record<string, { duration: number; bitrate: number; title?: string }> = {
|
||||
'/tmp/02 - Middle.mp3': { duration: 60, bitrate: 128000 },
|
||||
'/tmp/01 - Start.mp3': { duration: 60, bitrate: 128000 },
|
||||
'/tmp/03 - End.mp3': { duration: 60, bitrate: 128000 },
|
||||
};
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
const matches = command.match(/"([^"]+)"/g) ?? [];
|
||||
const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : '';
|
||||
const probe = probeMap[filePath];
|
||||
if (!probe) {
|
||||
throw new Error(`Missing probe data for ${filePath}`);
|
||||
}
|
||||
const payload = {
|
||||
format: {
|
||||
duration: String(probe.duration),
|
||||
bit_rate: String(probe.bitrate),
|
||||
tags: {},
|
||||
},
|
||||
};
|
||||
return { stdout: JSON.stringify(payload) };
|
||||
});
|
||||
|
||||
const ordered = await analyzeChapterFiles(files);
|
||||
|
||||
expect(ordered.map((file) => path.basename(file.path))).toEqual([
|
||||
'01 - Start.mp3',
|
||||
'02 - Middle.mp3',
|
||||
'03 - End.mp3',
|
||||
]);
|
||||
expect(ordered[0].chapterTitle).toBe('Start');
|
||||
expect(ordered[1].chapterTitle).toBe('Middle');
|
||||
});
|
||||
|
||||
it('falls back to chapter numbers when metadata title is the book title', async () => {
|
||||
const files = ['/tmp/01.mp3', '/tmp/02.mp3', '/tmp/03.mp3'];
|
||||
const probeMap: Record<string, { duration: number; bitrate: number; track: number; title: string }> = {
|
||||
'/tmp/01.mp3': { duration: 60, bitrate: 128000, track: 1, title: 'Book Title' },
|
||||
'/tmp/02.mp3': { duration: 60, bitrate: 128000, track: 2, title: 'Book Title' },
|
||||
'/tmp/03.mp3': { duration: 60, bitrate: 128000, track: 3, title: 'Book Title' },
|
||||
};
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
const matches = command.match(/"([^"]+)"/g) ?? [];
|
||||
const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : '';
|
||||
const probe = probeMap[filePath];
|
||||
if (!probe) {
|
||||
throw new Error(`Missing probe data for ${filePath}`);
|
||||
}
|
||||
const payload = {
|
||||
format: {
|
||||
duration: String(probe.duration),
|
||||
bit_rate: String(probe.bitrate),
|
||||
tags: { track: String(probe.track), title: probe.title },
|
||||
},
|
||||
};
|
||||
return { stdout: JSON.stringify(payload) };
|
||||
});
|
||||
|
||||
const ordered = await analyzeChapterFiles(files);
|
||||
|
||||
expect(ordered[0].chapterTitle).toBe('Chapter 1');
|
||||
expect(ordered[1].chapterTitle).toBe('Chapter 2');
|
||||
});
|
||||
|
||||
it('uses filename order when track numbers are not sequential', async () => {
|
||||
const files = ['/tmp/02 - Two.mp3', '/tmp/01 - One.mp3', '/tmp/03 - Three.mp3'];
|
||||
const probeMap: Record<string, { duration: number; bitrate: number; track: number }> = {
|
||||
'/tmp/02 - Two.mp3': { duration: 60, bitrate: 128000, track: 2 },
|
||||
'/tmp/01 - One.mp3': { duration: 60, bitrate: 128000, track: 1 },
|
||||
'/tmp/03 - Three.mp3': { duration: 60, bitrate: 128000, track: 4 },
|
||||
};
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
const matches = command.match(/"([^"]+)"/g) ?? [];
|
||||
const filePath = matches.length > 0 ? matches[matches.length - 1].replace(/"/g, '') : '';
|
||||
const probe = probeMap[filePath];
|
||||
if (!probe) {
|
||||
throw new Error(`Missing probe data for ${filePath}`);
|
||||
}
|
||||
const payload = {
|
||||
format: {
|
||||
duration: String(probe.duration),
|
||||
bit_rate: String(probe.bitrate),
|
||||
tags: { track: String(probe.track) },
|
||||
},
|
||||
};
|
||||
return { stdout: JSON.stringify(payload) };
|
||||
});
|
||||
|
||||
const ordered = await analyzeChapterFiles(files);
|
||||
|
||||
expect(ordered.map((file) => path.basename(file.path))).toEqual([
|
||||
'01 - One.mp3',
|
||||
'02 - Two.mp3',
|
||||
'03 - Three.mp3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('formats durations for logs', () => {
|
||||
expect(formatDuration(65000)).toBe('1m 5s');
|
||||
expect(formatDuration(3601000)).toBe('1h 0m 1s');
|
||||
});
|
||||
|
||||
it('estimates output size with overhead', async () => {
|
||||
fsMock.stat.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === '/tmp/one.mp3') return { size: 100 };
|
||||
if (filePath === '/tmp/two.mp3') return { size: 200 };
|
||||
throw new Error('missing');
|
||||
});
|
||||
|
||||
const size = await estimateOutputSize(['/tmp/one.mp3', '/tmp/two.mp3', '/tmp/missing.mp3']);
|
||||
|
||||
expect(size).toBe(330);
|
||||
});
|
||||
|
||||
it('checks disk space when df output is available', async () => {
|
||||
mockExecImplementation(() => ({ stdout: '1024\n' }));
|
||||
|
||||
const space = await checkDiskSpace('/tmp');
|
||||
|
||||
expect(space).toBe(1024 * 1024);
|
||||
});
|
||||
|
||||
it('returns null when disk space cannot be determined', async () => {
|
||||
mockExecImplementation(() => ({ error: new Error('df missing') }));
|
||||
|
||||
const space = await checkDiskSpace('/tmp');
|
||||
|
||||
expect(space).toBeNull();
|
||||
});
|
||||
|
||||
it('returns an error when no chapters are provided', async () => {
|
||||
const result = await mergeChapters([], {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
outputPath: '/tmp/output.m4b',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No chapters');
|
||||
});
|
||||
|
||||
it('merges chapters and returns success details', async () => {
|
||||
const outputPath = '/tmp/output.m4b';
|
||||
|
||||
const chapters = [
|
||||
{ path: '/tmp/one.mp3', filename: 'one.mp3', duration: 60000, bitrate: 128, chapterTitle: 'One' },
|
||||
{ path: '/tmp/two.mp3', filename: 'two.mp3', duration: 60000, bitrate: 128, chapterTitle: 'Two' },
|
||||
];
|
||||
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === outputPath) {
|
||||
return { size: 2 * 1024 * 1024 };
|
||||
}
|
||||
return { size: 500 * 1024 };
|
||||
});
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
if (command.startsWith('ffmpeg -encoders')) {
|
||||
return { stdout: 'aac encoder' };
|
||||
}
|
||||
if (command.startsWith('ffprobe')) {
|
||||
const payload = {
|
||||
format: {
|
||||
duration: '120',
|
||||
bit_rate: '128000',
|
||||
tags: {},
|
||||
},
|
||||
};
|
||||
return { stdout: JSON.stringify(payload) };
|
||||
}
|
||||
if (command.startsWith('ffmpeg -v error')) {
|
||||
return { stdout: '' };
|
||||
}
|
||||
return { error: new Error(`Unexpected command: ${command}`) };
|
||||
});
|
||||
|
||||
spawnMock.mockReturnValue(createSpawnProcess(0));
|
||||
|
||||
const result = await mergeChapters(chapters, {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
outputPath,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.chapterCount).toBe(2);
|
||||
expect(result.totalDuration).toBe(120000);
|
||||
expect(spawnMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('parses probe metadata including track numbers', async () => {
|
||||
mockExecImplementation(() => ({
|
||||
stdout: JSON.stringify({
|
||||
format: {
|
||||
duration: '90',
|
||||
bit_rate: '256000',
|
||||
tags: { track: '1/10', title: 'Chapter One' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const probe = await probeAudioFile('/tmp/chapter.mp3');
|
||||
|
||||
expect(probe.duration).toBe(90000);
|
||||
expect(probe.bitrate).toBe(256);
|
||||
expect(probe.trackNumber).toBe(1);
|
||||
expect(probe.title).toBe('Chapter One');
|
||||
});
|
||||
|
||||
it('returns failure when ffmpeg merge fails', async () => {
|
||||
const chapters = [
|
||||
{ path: '/tmp/one.mp3', filename: 'one.mp3', duration: 60000, bitrate: 128, chapterTitle: 'One' },
|
||||
];
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockResolvedValue({ size: 500 * 1024 });
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
if (command.startsWith('ffmpeg -encoders')) {
|
||||
return { stdout: 'aac encoder' };
|
||||
}
|
||||
return { stdout: '' };
|
||||
});
|
||||
|
||||
spawnMock.mockReturnValue(createSpawnProcess(1, 'Error: merge failed'));
|
||||
|
||||
const result = await mergeChapters(chapters, {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
outputPath: '/tmp/output.m4b',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/FFmpeg merge failed/i);
|
||||
});
|
||||
|
||||
it('returns failure when output validation fails', async () => {
|
||||
const outputPath = '/tmp/output.m4b';
|
||||
const chapters = [
|
||||
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
|
||||
{ path: '/tmp/two.m4a', filename: 'two.m4a', duration: 60000, bitrate: 128, chapterTitle: 'Two' },
|
||||
];
|
||||
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === outputPath) {
|
||||
return { size: 2 * 1024 * 1024 };
|
||||
}
|
||||
return { size: 500 * 1024 };
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
if (command.startsWith('ffprobe')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
format: {
|
||||
duration: '30',
|
||||
bit_rate: '128000',
|
||||
tags: {},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { stdout: '' };
|
||||
});
|
||||
|
||||
spawnMock.mockReturnValue(createSpawnProcess(0));
|
||||
|
||||
const result = await mergeChapters(chapters, {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
outputPath,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/Merge validation failed/i);
|
||||
});
|
||||
|
||||
it('returns failure when file integrity validation fails', async () => {
|
||||
const outputPath = '/tmp/output.m4b';
|
||||
const chapters = [
|
||||
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
|
||||
{ path: '/tmp/two.m4a', filename: 'two.m4a', duration: 60000, bitrate: 128, chapterTitle: 'Two' },
|
||||
];
|
||||
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockResolvedValue({ size: 500 * 1024 });
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
if (command.startsWith('ffprobe')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
format: {
|
||||
duration: '120',
|
||||
bit_rate: '128000',
|
||||
tags: {},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command.startsWith('ffmpeg -v error')) {
|
||||
return { error: new Error('decode failed') };
|
||||
}
|
||||
return { stdout: '' };
|
||||
});
|
||||
|
||||
spawnMock.mockReturnValue(createSpawnProcess(0));
|
||||
|
||||
const result = await mergeChapters(chapters, {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
outputPath,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/File integrity test failed/i);
|
||||
});
|
||||
|
||||
it('returns failure when merged file size is too small', async () => {
|
||||
const outputPath = '/tmp/output.m4b';
|
||||
const chapters = [
|
||||
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
|
||||
{ path: '/tmp/two.m4a', filename: 'two.m4a', duration: 60000, bitrate: 128, chapterTitle: 'Two' },
|
||||
];
|
||||
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === outputPath) {
|
||||
return { size: 200 * 1024 };
|
||||
}
|
||||
return { size: 500 * 1024 };
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
if (command.startsWith('ffprobe')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
format: {
|
||||
duration: '120',
|
||||
bit_rate: '128000',
|
||||
tags: {},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command.startsWith('ffmpeg -v error')) {
|
||||
return { stdout: '' };
|
||||
}
|
||||
return { stdout: '' };
|
||||
});
|
||||
|
||||
spawnMock.mockReturnValue(createSpawnProcess(0));
|
||||
|
||||
const result = await mergeChapters(chapters, {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
outputPath,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/File size too small/i);
|
||||
});
|
||||
|
||||
it('returns failure when validation encounters an error', async () => {
|
||||
const outputPath = '/tmp/output.m4b';
|
||||
const chapters = [
|
||||
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
|
||||
];
|
||||
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockResolvedValue({ size: 500 * 1024 });
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
if (command.startsWith('ffprobe')) {
|
||||
return { error: new Error('probe failed') };
|
||||
}
|
||||
return { stdout: '' };
|
||||
});
|
||||
|
||||
spawnMock.mockReturnValue(createSpawnProcess(0));
|
||||
|
||||
const result = await mergeChapters(chapters, {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
outputPath,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/Validation error/i);
|
||||
});
|
||||
|
||||
it('logs encoding estimates for long MP3 audiobooks', async () => {
|
||||
const outputPath = '/tmp/output.m4b';
|
||||
const chapters = [
|
||||
{ path: '/tmp/one.mp3', filename: 'one.mp3', duration: 3600000, bitrate: 128, chapterTitle: 'One' },
|
||||
{ path: '/tmp/two.mp3', filename: 'two.mp3', duration: 3600000, bitrate: 128, chapterTitle: 'Two' },
|
||||
];
|
||||
const logger = {
|
||||
info: vi.fn().mockResolvedValue(undefined),
|
||||
warn: vi.fn().mockResolvedValue(undefined),
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === outputPath) {
|
||||
return { size: 120 * 1024 * 1024 };
|
||||
}
|
||||
return { size: 500 * 1024 };
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
mockExecImplementation((command) => {
|
||||
if (command.startsWith('ffmpeg -encoders')) {
|
||||
return { stdout: 'libfdk_aac' };
|
||||
}
|
||||
if (command.startsWith('ffprobe')) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
format: {
|
||||
duration: '7200',
|
||||
bit_rate: '128000',
|
||||
tags: {},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (command.startsWith('ffmpeg -v error')) {
|
||||
return { stdout: '' };
|
||||
}
|
||||
return { stdout: '' };
|
||||
});
|
||||
|
||||
spawnMock.mockReturnValue(createSpawnProcess(0));
|
||||
|
||||
const result = await mergeChapters(chapters, {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
outputPath,
|
||||
}, logger);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('long audiobook'));
|
||||
});
|
||||
|
||||
it('returns failure when output file is not created', async () => {
|
||||
const outputPath = '/tmp/output.m4b';
|
||||
const chapters = [
|
||||
{ path: '/tmp/one.m4a', filename: 'one.m4a', duration: 60000, bitrate: 128, chapterTitle: 'One' },
|
||||
];
|
||||
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === outputPath) {
|
||||
throw new Error('missing');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
fsMock.stat.mockResolvedValue({ size: 500 * 1024 });
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
mockExecImplementation(() => ({ stdout: '' }));
|
||||
spawnMock.mockReturnValue(createSpawnProcess(0));
|
||||
|
||||
const result = await mergeChapters(chapters, {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
outputPath,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/Merged file not created/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Component: Cron Utilities Tests
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cronToHuman, isValidCron, customScheduleToCron, cronToCustomSchedule } from '@/lib/utils/cron';
|
||||
|
||||
describe('cron utilities', () => {
|
||||
it('converts known presets to human text', () => {
|
||||
expect(cronToHuman('*/15 * * * *')).toBe('Every 15 minutes');
|
||||
expect(cronToHuman('0 */6 * * *')).toBe('Every 6 hours');
|
||||
expect(cronToHuman('0 * * * *')).toBe('Every hour');
|
||||
});
|
||||
|
||||
it('converts daily schedule to human text', () => {
|
||||
expect(cronToHuman('30 14 * * *')).toBe('Daily at 2:30 PM');
|
||||
expect(cronToHuman('*/1 * * * *')).toBe('Every 1 minute');
|
||||
});
|
||||
|
||||
it('converts weekly and monthly schedules to human text', () => {
|
||||
expect(cronToHuman('15 9 * * 1')).toBe('Weekly on Monday at 9:15 AM');
|
||||
expect(cronToHuman('0 0 15 * *')).toBe('Monthly on day 15 at 12:00 AM');
|
||||
});
|
||||
|
||||
it('returns raw cron for invalid expressions', () => {
|
||||
expect(cronToHuman('bad cron')).toBe('bad cron');
|
||||
});
|
||||
|
||||
it('validates cron expressions', () => {
|
||||
expect(isValidCron('*/5 * * * *')).toBe(true);
|
||||
expect(isValidCron('invalid')).toBe(false);
|
||||
expect(isValidCron('0 0 0 * *')).toBe(false);
|
||||
expect(isValidCron('0 0 1-5 * *')).toBe(true);
|
||||
expect(isValidCron('0 0 1,15 * *')).toBe(true);
|
||||
expect(isValidCron('*/0 * * * *')).toBe(false);
|
||||
});
|
||||
|
||||
it('converts custom schedules to cron', () => {
|
||||
expect(customScheduleToCron({ type: 'minutes', interval: 10 })).toBe('*/10 * * * *');
|
||||
expect(customScheduleToCron({ type: 'hours', interval: 24 })).toBe('0 0 * * *');
|
||||
expect(customScheduleToCron({ type: 'daily', time: { hour: 9, minute: 15 } })).toBe('15 9 * * *');
|
||||
expect(customScheduleToCron({ type: 'weekly', time: { hour: 6, minute: 30 }, dayOfWeek: 2 })).toBe('30 6 * * 2');
|
||||
expect(customScheduleToCron({ type: 'monthly', time: { hour: 5, minute: 0 }, dayOfMonth: 10 })).toBe('0 5 10 * *');
|
||||
expect(customScheduleToCron({ type: 'custom', customCron: '5 4 * * *' })).toBe('5 4 * * *');
|
||||
});
|
||||
|
||||
it('parses cron into custom schedules', () => {
|
||||
expect(cronToCustomSchedule('*/15 * * * *')).toEqual({ type: 'minutes', interval: 15 });
|
||||
expect(cronToCustomSchedule('0 */3 * * *')).toEqual({ type: 'hours', interval: 3 });
|
||||
expect(cronToCustomSchedule('0 7 * * *')).toEqual({ type: 'daily', time: { hour: 7, minute: 0 } });
|
||||
expect(cronToCustomSchedule('0 6 * * 2')).toEqual({
|
||||
type: 'weekly',
|
||||
time: { hour: 6, minute: 0 },
|
||||
dayOfWeek: 2,
|
||||
});
|
||||
expect(cronToCustomSchedule('0 2 12 * *')).toEqual({
|
||||
type: 'monthly',
|
||||
time: { hour: 2, minute: 0 },
|
||||
dayOfMonth: 12,
|
||||
});
|
||||
expect(cronToCustomSchedule('bad')).toEqual({ type: 'custom', customCron: 'bad' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* Component: File Organization System Tests
|
||||
* Documentation: documentation/phase3/file-organization.md
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FileOrganizer, getFileOrganizer } from '@/lib/utils/file-organizer';
|
||||
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
copyFile: vi.fn(),
|
||||
chmod: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
constants: { R_OK: 4 },
|
||||
}));
|
||||
|
||||
const axiosMock = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
|
||||
const jobLoggerMock = vi.hoisted(() => ({
|
||||
createJobLogger: vi.fn(),
|
||||
}));
|
||||
|
||||
const metadataMock = vi.hoisted(() => ({
|
||||
tagMultipleFiles: vi.fn(),
|
||||
checkFfmpegAvailable: vi.fn(),
|
||||
}));
|
||||
|
||||
const chapterMock = vi.hoisted(() => ({
|
||||
detectChapterFiles: vi.fn(),
|
||||
analyzeChapterFiles: vi.fn(),
|
||||
mergeChapters: vi.fn(),
|
||||
formatDuration: vi.fn((ms: number) => `${ms}`),
|
||||
estimateOutputSize: vi.fn(),
|
||||
checkDiskSpace: vi.fn(),
|
||||
}));
|
||||
|
||||
const loggerMock = vi.hoisted(() => ({
|
||||
RMABLogger: {
|
||||
create: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
const configState = vi.hoisted(() => ({
|
||||
values: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
configuration: {
|
||||
findUnique: vi.fn(async ({ where: { key } }: { where: { key: string } }) => {
|
||||
const value = configState.values.get(key);
|
||||
return value !== undefined ? { value } : null;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const ebookMock = vi.hoisted(() => ({
|
||||
downloadEbook: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
}));
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: axiosMock,
|
||||
...axiosMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/job-logger', () => jobLoggerMock);
|
||||
vi.mock('@/lib/utils/metadata-tagger', () => metadataMock);
|
||||
vi.mock('@/lib/utils/chapter-merger', () => chapterMock);
|
||||
vi.mock('@/lib/utils/logger', () => loggerMock);
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
vi.mock('@/lib/services/ebook-scraper', () => ebookMock);
|
||||
|
||||
describe('file organizer', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configState.values.clear();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('organizes a single file and copies cached cover art', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
fsMock.stat.mockResolvedValue({ isFile: () => true });
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === '/downloads/book.m4b') return undefined;
|
||||
if (filePath === '/app/cache/thumbnails/cover.jpg') return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const logger = {
|
||||
info: vi.fn().mockResolvedValue(undefined),
|
||||
warn: vi.fn().mockResolvedValue(undefined),
|
||||
error: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
jobLoggerMock.createJobLogger.mockReturnValue(logger);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
const result = await organizer.organize(
|
||||
'/downloads/book.m4b',
|
||||
{
|
||||
title: 'Book: Title',
|
||||
author: 'Author/Name',
|
||||
year: 2020,
|
||||
asin: 'ASIN123',
|
||||
coverArtUrl: '/api/cache/thumbnails/cover.jpg',
|
||||
},
|
||||
{ jobId: 'job-1', context: 'organize' }
|
||||
);
|
||||
|
||||
const expectedDir = path.join('/media', 'AuthorName', 'Book Title (2020) ASIN123');
|
||||
const expectedAudio = path.join(expectedDir, 'book.m4b');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.targetPath).toBe(expectedDir);
|
||||
expect(result.audioFiles).toEqual([expectedAudio]);
|
||||
expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg'));
|
||||
expect(result.filesMovedCount).toBe(1);
|
||||
expect(jobLoggerMock.createJobLogger).toHaveBeenCalledWith('job-1', 'organize');
|
||||
expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns errors when no audiobook files are found', async () => {
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: [],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const result = await organizer.organize('/downloads/empty', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('No audiobook files found in download');
|
||||
});
|
||||
|
||||
it('falls back when chapter merge fails and continues organizing', async () => {
|
||||
configState.values.set('chapter_merging_enabled', 'true');
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
chapterMock.detectChapterFiles.mockResolvedValue(true);
|
||||
chapterMock.estimateOutputSize.mockResolvedValue(100);
|
||||
chapterMock.checkDiskSpace.mockResolvedValue(1000);
|
||||
chapterMock.analyzeChapterFiles.mockResolvedValue([
|
||||
{ path: '/downloads/book/disc1.mp3', filename: 'disc1.mp3', duration: 1000, chapterTitle: 'One' },
|
||||
]);
|
||||
chapterMock.mergeChapters.mockResolvedValue({ success: false, error: 'merge failed' });
|
||||
|
||||
const downloadRoot = path.normalize(path.join('/downloads', 'book'));
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath).startsWith(downloadRoot)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['disc1.mp3', 'disc2.mp3'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.filesMovedCount).toBe(2);
|
||||
expect(result.errors.join(' ')).toContain('Chapter merge failed');
|
||||
expect(chapterMock.mergeChapters).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses tagged files when metadata tagging succeeds', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'true');
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
metadataMock.checkFfmpegAvailable.mockResolvedValue(true);
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
metadataMock.tagMultipleFiles.mockResolvedValue([
|
||||
{
|
||||
success: true,
|
||||
filePath: sourcePath,
|
||||
taggedFilePath: '/tmp/tagged.m4b',
|
||||
},
|
||||
]);
|
||||
|
||||
const downloadRoot = path.normalize(path.join('/downloads', 'book'));
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize('/tmp/tagged.m4b')) return undefined;
|
||||
if (path.normalize(filePath).startsWith(downloadRoot)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
const expectedDir = path.join('/media', 'Author', 'Book');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.targetPath).toBe(expectedDir);
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith('/tmp/tagged.m4b', path.join(expectedDir, 'book.m4b'));
|
||||
expect(fsMock.unlink).toHaveBeenCalledWith('/tmp/tagged.m4b');
|
||||
});
|
||||
|
||||
it('skips metadata tagging when ffmpeg is unavailable', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'true');
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
metadataMock.checkFfmpegAvailable.mockResolvedValue(false);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
const expectedDir = path.join('/media', 'Author', 'Book');
|
||||
const targetFile = path.join(expectedDir, 'book.m4b');
|
||||
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toContain('Metadata tagging skipped: ffmpeg not available');
|
||||
expect(metadataMock.tagMultipleFiles).not.toHaveBeenCalled();
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
});
|
||||
|
||||
it('downloads remote cover art and ebook sidecar when enabled', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
configState.values.set('ebook_sidecar_preferred_format', 'epub');
|
||||
configState.values.set('ebook_sidecar_base_url', 'https://ebooks.example');
|
||||
configState.values.set('ebook_sidecar_flaresolverr_url', 'http://flaresolverr');
|
||||
|
||||
ebookMock.downloadEbook.mockResolvedValue({
|
||||
success: true,
|
||||
filePath: '/media/Author/Book/book.epub',
|
||||
});
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
const expectedDir = path.join('/media', 'Author', 'Book ASIN123');
|
||||
const targetFile = path.join(expectedDir, 'book.m4b');
|
||||
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
|
||||
axiosMock.get.mockResolvedValue({ data: Buffer.from('cover') });
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
asin: 'ASIN123',
|
||||
coverArtUrl: 'https://images.example/cover.jpg',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.coverArtFile).toBe(path.join(expectedDir, 'cover.jpg'));
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'https://images.example/cover.jpg',
|
||||
expect.objectContaining({ responseType: 'arraybuffer' })
|
||||
);
|
||||
expect(ebookMock.downloadEbook).toHaveBeenCalledWith(
|
||||
'ASIN123',
|
||||
'Book',
|
||||
'Author',
|
||||
expectedDir,
|
||||
'epub',
|
||||
'https://ebooks.example',
|
||||
undefined,
|
||||
'http://flaresolverr'
|
||||
);
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
expect(result.filesMovedCount).toBe(2);
|
||||
});
|
||||
|
||||
it('records an error when cover art download fails', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
const expectedDir = path.join('/media', 'Author', 'Book');
|
||||
const targetFile = path.join(expectedDir, 'book.m4b');
|
||||
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
axiosMock.get.mockRejectedValue(new Error('cover failed'));
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
coverArtUrl: 'https://images.example/cover.jpg',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors.join(' ')).toContain('Failed to download cover art');
|
||||
expect(fsMock.copyFile).toHaveBeenCalledWith(sourcePath, targetFile);
|
||||
});
|
||||
|
||||
it('continues when chapter analysis returns no valid chapters', async () => {
|
||||
configState.values.set('chapter_merging_enabled', 'true');
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
chapterMock.detectChapterFiles.mockResolvedValue(true);
|
||||
chapterMock.estimateOutputSize.mockResolvedValue(100);
|
||||
chapterMock.checkDiskSpace.mockResolvedValue(1000);
|
||||
chapterMock.analyzeChapterFiles.mockResolvedValue([]);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['disc1.mp3', 'disc2.mp3'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourceRoot = path.normalize('/downloads/book');
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath).startsWith(sourceRoot)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.filesMovedCount).toBe(2);
|
||||
expect(chapterMock.mergeChapters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records errors when some metadata tagging operations fail', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'true');
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
metadataMock.checkFfmpegAvailable.mockResolvedValue(true);
|
||||
metadataMock.tagMultipleFiles.mockResolvedValue([
|
||||
{ success: true, filePath: '/downloads/book/one.m4b', taggedFilePath: '/tmp/one-tagged.m4b' },
|
||||
{ success: false, filePath: '/downloads/book/two.m4b', error: 'bad tags' },
|
||||
]);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['one.m4b', 'two.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourceRoot = path.normalize('/downloads/book');
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize('/tmp/one-tagged.m4b')) return undefined;
|
||||
if (path.normalize(filePath).startsWith(sourceRoot)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors.join(' ')).toContain('Failed to tag 1 file(s) with metadata');
|
||||
});
|
||||
|
||||
it('records ebook sidecar errors when download throws', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'true');
|
||||
|
||||
ebookMock.downloadEbook.mockRejectedValue(new Error('ebook down'));
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toContain('E-book sidecar failed');
|
||||
});
|
||||
|
||||
it('finds audio files and cover art in nested folders', async () => {
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
|
||||
fsMock.stat.mockResolvedValue({ isFile: () => false });
|
||||
const subDir = path.join('/downloads', 'sub');
|
||||
fsMock.readdir.mockImplementation(async (dir: string) => {
|
||||
if (dir === '/downloads') {
|
||||
return [
|
||||
{ name: 'disc1.mp3', isDirectory: () => false },
|
||||
{ name: 'sub', isDirectory: () => true },
|
||||
];
|
||||
}
|
||||
if (dir === subDir) {
|
||||
return [
|
||||
{ name: 'disc2.mp3', isDirectory: () => false },
|
||||
{ name: 'cover.jpg', isDirectory: () => false },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const result = await (organizer as any).findAudiobookFiles('/downloads');
|
||||
|
||||
expect(result.audioFiles).toEqual([
|
||||
'disc1.mp3',
|
||||
path.join('sub', 'disc2.mp3'),
|
||||
]);
|
||||
expect(result.coverFile).toBe(path.join('sub', 'cover.jpg'));
|
||||
expect(result.isFile).toBe(false);
|
||||
});
|
||||
|
||||
it('returns no audio files for unsupported single files', async () => {
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
fsMock.stat.mockResolvedValue({ isFile: () => true });
|
||||
|
||||
const result = await (organizer as any).findAudiobookFiles('/downloads/readme.txt');
|
||||
|
||||
expect(result.audioFiles).toEqual([]);
|
||||
expect(result.isFile).toBe(true);
|
||||
});
|
||||
|
||||
it('adds errors when source audio files are missing', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize(sourcePath)) {
|
||||
throw new Error('missing');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors.join(' ')).toContain('Source file not found');
|
||||
expect(fsMock.copyFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips copying when target files already exist', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'false');
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
const targetDir = path.join('/media', 'Author', 'Book');
|
||||
const targetPath = path.join(targetDir, 'book.m4b');
|
||||
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
|
||||
if (path.normalize(filePath) === path.normalize(targetPath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.audioFiles).toEqual([targetPath]);
|
||||
expect(result.filesMovedCount).toBe(0);
|
||||
expect(fsMock.copyFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('continues when metadata tagging throws', async () => {
|
||||
configState.values.set('metadata_tagging_enabled', 'true');
|
||||
configState.values.set('ebook_sidecar_enabled', 'false');
|
||||
|
||||
metadataMock.checkFfmpegAvailable.mockRejectedValue(new Error('ffmpeg error'));
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
(organizer as any).findAudiobookFiles = vi.fn().mockResolvedValue({
|
||||
audioFiles: ['book.m4b'],
|
||||
coverFile: undefined,
|
||||
isFile: false,
|
||||
});
|
||||
|
||||
const sourcePath = path.join('/downloads', 'book', 'book.m4b');
|
||||
fsMock.access.mockImplementation(async (filePath: string) => {
|
||||
if (path.normalize(filePath) === path.normalize(sourcePath)) return undefined;
|
||||
throw new Error('missing');
|
||||
});
|
||||
fsMock.mkdir.mockResolvedValue(undefined);
|
||||
fsMock.copyFile.mockResolvedValue(undefined);
|
||||
fsMock.chmod.mockResolvedValue(undefined);
|
||||
|
||||
const result = await organizer.organize('/downloads/book', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors.join(' ')).toContain('Metadata tagging failed');
|
||||
expect(fsMock.copyFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates paths and reports multiple issues', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockResolvedValue({ isDirectory: () => false });
|
||||
fsMock.writeFile.mockRejectedValue(new Error('not writable'));
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
const result = await organizer.validate('/media');
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.issues).toContain('Path is not a directory');
|
||||
expect(result.issues).toContain('Directory is not writable');
|
||||
});
|
||||
|
||||
it('returns validation errors when path is missing', async () => {
|
||||
fsMock.access.mockRejectedValue(new Error('missing'));
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
const result = await organizer.validate('/missing');
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.issues.join(' ')).toContain('Path does not exist');
|
||||
});
|
||||
|
||||
it('throws when the download directory cannot be read', async () => {
|
||||
fsMock.stat.mockRejectedValue(new Error('bad path'));
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
await expect((organizer as any).findAudiobookFiles('/downloads/bad')).rejects.toThrow('bad path');
|
||||
});
|
||||
|
||||
it('returns an empty list when walkDirectory fails', async () => {
|
||||
fsMock.readdir.mockRejectedValue(new Error('no perms'));
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
const files = await (organizer as any).walkDirectory('/downloads');
|
||||
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it('cleans up download directories safely', async () => {
|
||||
fsMock.rm.mockRejectedValue(new Error('rm failed'));
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
await expect(organizer.cleanup('/downloads/book')).resolves.toBeUndefined();
|
||||
expect(fsMock.rm).toHaveBeenCalledWith('/downloads/book', { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('cleans up download directories on success', async () => {
|
||||
fsMock.rm.mockResolvedValue(undefined);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
await expect(organizer.cleanup('/downloads/book')).resolves.toBeUndefined();
|
||||
expect(fsMock.rm).toHaveBeenCalledWith('/downloads/book', { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('validates writable directories without issues', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.stat.mockResolvedValue({ isDirectory: () => true });
|
||||
fsMock.writeFile.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
|
||||
const organizer = new FileOrganizer('/media', '/tmp');
|
||||
const result = await organizer.validate('/media');
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.issues).toEqual([]);
|
||||
expect(fsMock.unlink).toHaveBeenCalledWith(path.join('/media', '.test-write'));
|
||||
});
|
||||
|
||||
it('builds organizer settings from configuration', async () => {
|
||||
configState.values.set('media_dir', '/media/custom');
|
||||
process.env.TEMP_DIR = '/tmp/custom';
|
||||
|
||||
const organizer = await getFileOrganizer();
|
||||
|
||||
expect((organizer as any).mediaDir).toBe('/media/custom');
|
||||
expect((organizer as any).tempDir).toBe('/tmp/custom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Component: Job Logger Utility Tests
|
||||
* Documentation: documentation/backend/services/jobs.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const infoMock = vi.fn();
|
||||
const warnMock = vi.fn();
|
||||
const errorMock = vi.fn();
|
||||
const forJobMock = vi.fn(() => ({
|
||||
info: infoMock,
|
||||
warn: warnMock,
|
||||
error: errorMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/logger', () => ({
|
||||
RMABLogger: {
|
||||
forJob: forJobMock,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('JobLogger', () => {
|
||||
it('logs info, warn, and error messages via RMABLogger', async () => {
|
||||
const { JobLogger } = await import('@/lib/utils/job-logger');
|
||||
const logger = new JobLogger('job-1', 'Context');
|
||||
|
||||
await logger.info('info message', { foo: 'bar' });
|
||||
await logger.warn('warn message');
|
||||
await logger.error('error message', { error: 'boom' });
|
||||
|
||||
expect(forJobMock).toHaveBeenCalledWith('job-1', 'Context');
|
||||
expect(infoMock).toHaveBeenCalledWith('info message', { foo: 'bar' });
|
||||
expect(warnMock).toHaveBeenCalledWith('warn message', undefined);
|
||||
expect(errorMock).toHaveBeenCalledWith('error message', { error: 'boom' });
|
||||
});
|
||||
|
||||
it('creates a job logger via helper', async () => {
|
||||
const { createJobLogger } = await import('@/lib/utils/job-logger');
|
||||
const logger = createJobLogger('job-2', 'Context2');
|
||||
|
||||
await logger.info('message');
|
||||
|
||||
expect(forJobMock).toHaveBeenCalledWith('job-2', 'Context2');
|
||||
expect(infoMock).toHaveBeenCalledWith('message', undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Component: Client-Side JWT Utilities Tests
|
||||
* Documentation: documentation/frontend/routing-auth.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const loggerState = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
create: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/logger', () => ({
|
||||
RMABLogger: {
|
||||
create: loggerState.create,
|
||||
},
|
||||
}));
|
||||
|
||||
const base64Url = (value: unknown) =>
|
||||
Buffer.from(JSON.stringify(value))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
const createToken = (payload: Record<string, unknown>) => {
|
||||
const header = base64Url({ alg: 'HS256', typ: 'JWT' });
|
||||
const body = base64Url(payload);
|
||||
return `${header}.${body}.signature`;
|
||||
};
|
||||
|
||||
describe('jwt client utilities', () => {
|
||||
const originalAtob = globalThis.atob;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
loggerState.error.mockClear();
|
||||
loggerState.create.mockReturnValue({ error: loggerState.error });
|
||||
|
||||
globalThis.atob = (input: string) => Buffer.from(input, 'base64').toString('binary');
|
||||
});
|
||||
|
||||
it('decodes a valid JWT payload', async () => {
|
||||
const { decodeJWT } = await import('@/lib/utils/jwt-client');
|
||||
|
||||
const token = createToken({ sub: 'user', exp: 2000, role: 'user' });
|
||||
const decoded = decodeJWT(token);
|
||||
|
||||
expect(decoded?.sub).toBe('user');
|
||||
expect(decoded?.exp).toBe(2000);
|
||||
});
|
||||
|
||||
it('returns null for invalid tokens', async () => {
|
||||
const { decodeJWT } = await import('@/lib/utils/jwt-client');
|
||||
|
||||
expect(decodeJWT('not-a-token')).toBeNull();
|
||||
});
|
||||
|
||||
it('logs an error when decoding fails', async () => {
|
||||
const { decodeJWT } = await import('@/lib/utils/jwt-client');
|
||||
|
||||
const decoded = decodeJWT('header.badbase64.signature');
|
||||
|
||||
expect(decoded).toBeNull();
|
||||
expect(loggerState.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks token expiry correctly', async () => {
|
||||
const { isTokenExpired } = await import('@/lib/utils/jwt-client');
|
||||
const now = 1700000000;
|
||||
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now * 1000);
|
||||
|
||||
const fresh = createToken({ exp: now + 60 });
|
||||
const expired = createToken({ exp: now - 60 });
|
||||
|
||||
expect(isTokenExpired(fresh)).toBe(false);
|
||||
expect(isTokenExpired(expired)).toBe(true);
|
||||
expect(isTokenExpired('invalid')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns expiry and refresh windows', async () => {
|
||||
const { getRefreshTimeMs, getTokenExpiryMs } = await import('@/lib/utils/jwt-client');
|
||||
const now = 1700000000;
|
||||
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now * 1000);
|
||||
|
||||
const token = createToken({ exp: now + 600 });
|
||||
const expiryMs = getTokenExpiryMs(token);
|
||||
const refreshMs = getRefreshTimeMs(token);
|
||||
|
||||
expect(expiryMs).toBe(600 * 1000);
|
||||
expect(refreshMs).toBe(300 * 1000);
|
||||
|
||||
const shortToken = createToken({ exp: now + 60 });
|
||||
expect(getRefreshTimeMs(shortToken)).toBe(0);
|
||||
expect(getTokenExpiryMs('invalid')).toBeNull();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalAtob) {
|
||||
globalThis.atob = originalAtob;
|
||||
} else {
|
||||
delete (globalThis as any).atob;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Component: JWT Utilities Tests
|
||||
* Documentation: documentation/backend/services/auth.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {
|
||||
decodeToken,
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
verifyAccessToken,
|
||||
verifyRefreshToken,
|
||||
} from '@/lib/utils/jwt';
|
||||
|
||||
describe('JWT utilities', () => {
|
||||
it('generates and verifies access tokens', () => {
|
||||
const token = generateAccessToken({
|
||||
sub: 'user-1',
|
||||
plexId: 'plex-1',
|
||||
username: 'user',
|
||||
role: 'admin',
|
||||
});
|
||||
|
||||
const payload = verifyAccessToken(token);
|
||||
|
||||
expect(payload?.sub).toBe('user-1');
|
||||
expect(payload?.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns null for invalid access tokens', () => {
|
||||
const payload = verifyAccessToken('bad-token');
|
||||
|
||||
expect(payload).toBeNull();
|
||||
});
|
||||
|
||||
it('generates and verifies refresh tokens', () => {
|
||||
const token = generateRefreshToken('user-2');
|
||||
const payload = verifyRefreshToken(token);
|
||||
|
||||
expect(payload?.sub).toBe('user-2');
|
||||
expect(payload?.type).toBe('refresh');
|
||||
});
|
||||
|
||||
it('returns null when refresh token type does not match', () => {
|
||||
const invalid = jwt.sign(
|
||||
{ sub: 'user-3', type: 'access' },
|
||||
'change-this-to-another-random-secret-key',
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
const payload = verifyRefreshToken(invalid);
|
||||
|
||||
expect(payload).toBeNull();
|
||||
});
|
||||
|
||||
it('decodes tokens without verification', () => {
|
||||
const token = generateAccessToken({
|
||||
sub: 'user-4',
|
||||
plexId: 'plex-4',
|
||||
username: 'user',
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
const decoded = decodeToken(token) as { sub?: string } | null;
|
||||
|
||||
expect(decoded?.sub).toBe('user-4');
|
||||
expect(decodeToken('not-a-jwt')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Component: Metadata Tagging Utility Tests
|
||||
* Documentation: documentation/phase3/file-organization.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { checkFfmpegAvailable, tagAudioFileMetadata, tagMultipleFiles } from '@/lib/utils/metadata-tagger';
|
||||
|
||||
const execMock = vi.hoisted(() => vi.fn());
|
||||
const fsMock = vi.hoisted(() => ({
|
||||
access: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
exec: execMock,
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
}));
|
||||
|
||||
function mockExecSuccess(stdout = 'ok') {
|
||||
execMock.mockImplementation((command: string, options: any, callback?: any) => {
|
||||
const cb = typeof options === 'function' ? options : callback;
|
||||
cb(null, stdout, '');
|
||||
});
|
||||
}
|
||||
|
||||
function mockExecFailure(message = 'ffmpeg error') {
|
||||
execMock.mockImplementation((command: string, options: any, callback?: any) => {
|
||||
const cb = typeof options === 'function' ? options : callback;
|
||||
cb(new Error(message), '', '');
|
||||
});
|
||||
}
|
||||
|
||||
describe('metadata tagger', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns an error for unsupported file formats', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
|
||||
const result = await tagAudioFileMetadata('/tmp/book.wav', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Unsupported file format');
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tags an m4b file with metadata', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
const result = await tagAudioFileMetadata('/tmp/book.m4b', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
narrator: 'Narrator',
|
||||
year: 2020,
|
||||
asin: 'ASIN123',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.taggedFilePath).toBe('/tmp/book.m4b.tmp');
|
||||
|
||||
const command = execMock.mock.calls[0][0] as string;
|
||||
expect(command).toContain('-metadata title="Book"');
|
||||
expect(command).toContain('-metadata album_artist="Author"');
|
||||
expect(command).toContain('-metadata composer="Narrator"');
|
||||
expect(command).toContain('-metadata date="2020"');
|
||||
expect(command).toContain('-metadata ----:com.apple.iTunes:ASIN="ASIN123"');
|
||||
expect(command).toContain('-f mp4');
|
||||
});
|
||||
|
||||
it('cleans up temp files and returns errors when ffmpeg fails', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
fsMock.unlink.mockResolvedValue(undefined);
|
||||
mockExecFailure('exec failed');
|
||||
|
||||
const result = await tagAudioFileMetadata('/tmp/book.mp3', {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
asin: 'ASIN123',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('ffmpeg failed');
|
||||
expect(fsMock.unlink).toHaveBeenCalledWith('/tmp/book.mp3.tmp');
|
||||
});
|
||||
|
||||
it('tags multiple files in sequence', async () => {
|
||||
fsMock.access.mockResolvedValue(undefined);
|
||||
mockExecSuccess('done');
|
||||
|
||||
const results = await tagMultipleFiles(['/tmp/one.m4a', '/tmp/two.m4a'], {
|
||||
title: 'Book',
|
||||
author: 'Author',
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((result) => result.success)).toBe(true);
|
||||
});
|
||||
|
||||
it('checks ffmpeg availability', async () => {
|
||||
mockExecSuccess('ffmpeg version');
|
||||
await expect(checkFfmpegAvailable()).resolves.toBe(true);
|
||||
|
||||
mockExecFailure('not installed');
|
||||
await expect(checkFfmpegAvailable()).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Component: Path Mapper Tests
|
||||
* Documentation: documentation/phase3/qbittorrent.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PathMapper } from '@/lib/utils/path-mapper';
|
||||
|
||||
describe('PathMapper', () => {
|
||||
it('returns original path when mapping is disabled', () => {
|
||||
const result = PathMapper.transform('/remote/path/book', {
|
||||
enabled: false,
|
||||
remotePath: '/remote/path',
|
||||
localPath: '/local/path',
|
||||
});
|
||||
|
||||
expect(result).toBe('/remote/path/book');
|
||||
});
|
||||
|
||||
it('transforms remote path to local path when enabled', () => {
|
||||
const result = PathMapper.transform('/remote/mnt/d/done/Book', {
|
||||
enabled: true,
|
||||
remotePath: '/remote/mnt/d/done',
|
||||
localPath: '/downloads',
|
||||
});
|
||||
|
||||
expect(result.replace(/\\/g, '/')).toBe('/downloads/Book');
|
||||
});
|
||||
|
||||
it('returns original path when remote prefix does not match', () => {
|
||||
const result = PathMapper.transform('/other/path/book', {
|
||||
enabled: true,
|
||||
remotePath: '/remote/path',
|
||||
localPath: '/local/path',
|
||||
});
|
||||
|
||||
expect(result).toBe('/other/path/book');
|
||||
});
|
||||
|
||||
it('validates mapping configuration when enabled', () => {
|
||||
expect(() =>
|
||||
PathMapper.validate({ enabled: true, remotePath: '', localPath: '/local' })
|
||||
).toThrow('Remote path cannot be empty');
|
||||
expect(() =>
|
||||
PathMapper.validate({ enabled: true, remotePath: '/remote', localPath: '' })
|
||||
).toThrow('Local path cannot be empty');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Component: Intelligent Ranking Algorithm Tests
|
||||
* Documentation: documentation/phase3/ranking-algorithm.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RankingAlgorithm, rankTorrents } from '@/lib/utils/ranking-algorithm';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
describe('ranking-algorithm', () => {
|
||||
const baseTorrent = {
|
||||
indexer: 'IndexerA',
|
||||
title: 'Great Book - Author Name',
|
||||
size: 30 * MB,
|
||||
seeders: 10,
|
||||
leechers: 1,
|
||||
publishDate: new Date('2024-01-01T00:00:00Z'),
|
||||
downloadUrl: 'magnet:?xt=urn:btih:abc',
|
||||
guid: 'guid-1',
|
||||
};
|
||||
|
||||
it('filters out results below 20 MB', () => {
|
||||
const small = { ...baseTorrent, guid: 'small', size: 10 * MB };
|
||||
const big = { ...baseTorrent, guid: 'big', size: 25 * MB };
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[small, big],
|
||||
{ title: 'Great Book', author: 'Author Name' }
|
||||
);
|
||||
|
||||
expect(ranked).toHaveLength(1);
|
||||
expect(ranked[0].guid).toBe('big');
|
||||
});
|
||||
|
||||
it('prefers strong title/author matches over weaker ones', () => {
|
||||
const good = { ...baseTorrent, guid: 'good', title: 'Great Book - Author Name' };
|
||||
const bad = { ...baseTorrent, guid: 'bad', title: 'Different Title - Other Author' };
|
||||
|
||||
const ranked = rankTorrents(
|
||||
[bad, good],
|
||||
{ title: 'Great Book', author: 'Author Name' }
|
||||
);
|
||||
|
||||
expect(ranked[0].guid).toBe('good');
|
||||
});
|
||||
|
||||
it('treats undefined seeders as full availability score (usenet)', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
const torrent = { ...baseTorrent, seeders: undefined };
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Great Book',
|
||||
author: 'Author Name',
|
||||
});
|
||||
|
||||
expect(breakdown.seederScore).toBe(15);
|
||||
});
|
||||
|
||||
it('assigns full size score for >= 1.0 MB/min', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
const torrent = { ...baseTorrent, size: 150 * MB };
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'Great Book',
|
||||
author: 'Author Name',
|
||||
durationMinutes: 100,
|
||||
});
|
||||
|
||||
expect(breakdown.sizeScore).toBe(15);
|
||||
});
|
||||
|
||||
it('applies word coverage filter for partial title matches', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
const torrent = { ...baseTorrent, title: 'The Wild Robot' };
|
||||
|
||||
const breakdown = algorithm.getScoreBreakdown(torrent, {
|
||||
title: 'The Wild Robot on the Island',
|
||||
author: 'Peter Brown',
|
||||
});
|
||||
|
||||
expect(breakdown.matchScore).toBe(0);
|
||||
});
|
||||
|
||||
it('adds seeder availability notes and weak match notes', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
const baseBreakdown = {
|
||||
formatScore: 0,
|
||||
sizeScore: 0,
|
||||
seederScore: 0,
|
||||
matchScore: 30,
|
||||
totalScore: 30,
|
||||
notes: [],
|
||||
};
|
||||
|
||||
const noSeeders = (algorithm as any).generateNotes(
|
||||
{ ...baseTorrent, seeders: 0 },
|
||||
baseBreakdown,
|
||||
120
|
||||
);
|
||||
expect(noSeeders.some((note: string) => note.includes('No seeders'))).toBe(true);
|
||||
expect(noSeeders.some((note: string) => note.includes('Weak title/author match'))).toBe(true);
|
||||
|
||||
const lowSeeders = (algorithm as any).generateNotes(
|
||||
{ ...baseTorrent, seeders: 3 },
|
||||
baseBreakdown,
|
||||
120
|
||||
);
|
||||
expect(lowSeeders.some((note: string) => note.includes('Low seeders'))).toBe(true);
|
||||
|
||||
const highSeeders = (algorithm as any).generateNotes(
|
||||
{ ...baseTorrent, seeders: 50 },
|
||||
baseBreakdown,
|
||||
120
|
||||
);
|
||||
expect(highSeeders.some((note: string) => note.includes('Excellent availability'))).toBe(true);
|
||||
});
|
||||
|
||||
it('adds format and size quality notes for MP3 files', () => {
|
||||
const algorithm = new RankingAlgorithm();
|
||||
const breakdown = {
|
||||
formatScore: 0,
|
||||
sizeScore: 0,
|
||||
seederScore: 0,
|
||||
matchScore: 50,
|
||||
totalScore: 50,
|
||||
notes: [],
|
||||
};
|
||||
|
||||
const highQuality = (algorithm as any).generateNotes(
|
||||
{ ...baseTorrent, format: 'MP3', size: 70 * MB },
|
||||
breakdown,
|
||||
60
|
||||
);
|
||||
expect(highQuality.some((note: string) => note.includes('Acceptable format'))).toBe(true);
|
||||
expect(highQuality.some((note: string) => note.includes('High quality'))).toBe(true);
|
||||
|
||||
const standardQuality = (algorithm as any).generateNotes(
|
||||
{ ...baseTorrent, format: 'MP3', size: 30 * MB },
|
||||
breakdown,
|
||||
60
|
||||
);
|
||||
expect(standardQuality.some((note: string) => note.includes('Standard quality'))).toBe(true);
|
||||
|
||||
const lowQuality = (algorithm as any).generateNotes(
|
||||
{ ...baseTorrent, format: 'MP3', size: 20 * MB },
|
||||
breakdown,
|
||||
60
|
||||
);
|
||||
expect(lowQuality.some((note: string) => note.includes('Low quality'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Component: Torrent Category Utils Tests
|
||||
* Documentation: documentation/phase3/prowlarr.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_CATEGORIES,
|
||||
TORRENT_CATEGORIES,
|
||||
areAllChildrenSelected,
|
||||
getChildIds,
|
||||
getParentId,
|
||||
isParentCategory,
|
||||
} from '@/lib/utils/torrent-categories';
|
||||
|
||||
describe('torrent categories', () => {
|
||||
it('returns child ids for parent categories', () => {
|
||||
expect(getChildIds(3000)).toContain(3030);
|
||||
expect(getChildIds(8000)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns parent id for child categories', () => {
|
||||
expect(getParentId(3030)).toBe(3000);
|
||||
expect(getParentId(9999)).toBeNull();
|
||||
});
|
||||
|
||||
it('checks if all children are selected', () => {
|
||||
const childIds = getChildIds(3000);
|
||||
expect(areAllChildrenSelected(3000, childIds)).toBe(true);
|
||||
expect(areAllChildrenSelected(3000, [])).toBe(false);
|
||||
});
|
||||
|
||||
it('detects parent categories', () => {
|
||||
expect(isParentCategory(3000)).toBe(true);
|
||||
expect(isParentCategory(3030)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps default categories stable', () => {
|
||||
expect(DEFAULT_CATEGORIES).toEqual([3030]);
|
||||
expect(TORRENT_CATEGORIES.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Component: URL Utilities Tests
|
||||
* Documentation: documentation/backend/services/environment.md
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getBaseUrl, getCallbackUrl } from '@/lib/utils/url';
|
||||
|
||||
const envBackup = { ...process.env };
|
||||
|
||||
describe('URL utilities', () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...envBackup };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...envBackup };
|
||||
});
|
||||
|
||||
it('prefers PUBLIC_URL and trims trailing slashes', () => {
|
||||
process.env.PUBLIC_URL = 'https://example.com/';
|
||||
process.env.NEXTAUTH_URL = 'https://next.example.com';
|
||||
process.env.BASE_URL = 'https://base.example.com';
|
||||
|
||||
const url = getBaseUrl();
|
||||
|
||||
expect(url).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('falls back to NEXTAUTH_URL when PUBLIC_URL is not set', () => {
|
||||
delete process.env.PUBLIC_URL;
|
||||
process.env.NEXTAUTH_URL = 'https://next.example.com/';
|
||||
|
||||
const url = getBaseUrl();
|
||||
|
||||
expect(url).toBe('https://next.example.com');
|
||||
});
|
||||
|
||||
it('uses BASE_URL and keeps invalid scheme values', () => {
|
||||
delete process.env.PUBLIC_URL;
|
||||
delete process.env.NEXTAUTH_URL;
|
||||
process.env.BASE_URL = 'example.com/';
|
||||
|
||||
const url = getBaseUrl();
|
||||
|
||||
expect(url).toBe('example.com');
|
||||
});
|
||||
|
||||
it('defaults to localhost in production when no env vars are set', () => {
|
||||
delete process.env.PUBLIC_URL;
|
||||
delete process.env.NEXTAUTH_URL;
|
||||
delete process.env.BASE_URL;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const url = getBaseUrl();
|
||||
|
||||
expect(url).toBe('http://localhost:3030');
|
||||
});
|
||||
|
||||
it('builds callback URLs with normalized paths', () => {
|
||||
process.env.PUBLIC_URL = 'https://example.com';
|
||||
|
||||
const url = getCallbackUrl('api/auth/oidc/callback');
|
||||
|
||||
expect(url).toBe('https://example.com/api/auth/oidc/callback');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user