Add reported-issues, Goodreads sync & notifs

Introduce user-reported-issues and Goodreads shelf sync features and wire them into notifications. Adds Prisma migrations and schema changes (ReportedIssue, GoodreadsShelf, GoodreadsBookMapping), API endpoints for reporting (POST /audiobooks/[asin]/report-issue) and admin management (list, resolve/dismiss, replace), and an admin UI section to view/dismiss/replace reported issues. Adds a new notification event (issue_reported) with updates to notification schemas, docs and provider handling, plus a notification-events constants file. Refactors request creation to use createRequestForUser service, adds a Goodreads sync processor/service/hooks/UI modals, a scrape-resilience util, and related tests and minor integration updates.
This commit is contained in:
kikootwo
2026-02-11 16:49:55 -05:00
parent b013538b63
commit 20c8fb0898
69 changed files with 4167 additions and 766 deletions
@@ -83,6 +83,7 @@ describe('Notification Triggers - Integration Tests', () => {
id: 'user-1',
role: 'user',
autoApproveRequests: false,
plexUsername: 'testuser',
});
prismaMock.request.create.mockResolvedValue({
@@ -150,6 +151,7 @@ describe('Notification Triggers - Integration Tests', () => {
id: 'user-1',
role: 'user',
autoApproveRequests: true,
plexUsername: 'testuser',
});
prismaMock.request.create.mockResolvedValue({
@@ -213,6 +215,7 @@ describe('Notification Triggers - Integration Tests', () => {
id: 'user-1',
role: 'user',
autoApproveRequests: true,
plexUsername: 'testuser',
});
prismaMock.request.create.mockResolvedValue({
+3 -3
View File
@@ -36,7 +36,7 @@ vi.mock('@/components/requests/RequestCard', () => ({
const getStatValue = (label: string) => {
const labelNode = screen.getByText(label);
const container = labelNode.parentElement;
const valueNode = container?.querySelector('p:nth-of-type(2)');
const valueNode = container?.querySelector('div:first-child');
return valueNode?.textContent;
};
@@ -55,7 +55,7 @@ describe('ProfilePage', () => {
const { default: ProfilePage } = await import('@/app/profile/page');
render(<ProfilePage />);
expect(screen.getByText('Authentication Required')).toBeInTheDocument();
expect(screen.getByText('Sign in required')).toBeInTheDocument();
expect(screen.getByText('Please log in to view your profile')).toBeInTheDocument();
});
@@ -87,7 +87,7 @@ describe('ProfilePage', () => {
expect(getStatValue('Total')).toBe('6');
expect(getStatValue('Active')).toBe('2');
expect(getStatValue('Waiting')).toBe('1');
expect(getStatValue('Completed')).toBe('1');
expect(getStatValue('Complete')).toBe('1');
expect(getStatValue('Failed')).toBe('1');
expect(getStatValue('Cancelled')).toBe('1');
@@ -27,7 +27,7 @@ describe('ChangePasswordModal', () => {
});
it('rejects submission when access token is missing', async () => {
const fetchMock = vi.fn();
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
vi.stubGlobal('fetch', fetchMock);
render(<ChangePasswordModal isOpen onClose={vi.fn()} />);
@@ -48,10 +48,8 @@ describe('ChangePasswordModal', () => {
expect(screen.getByText('Not authenticated')).toBeInTheDocument();
});
expect(fetchMock).not.toHaveBeenCalledWith(
'/api/auth/change-password',
expect.anything()
);
// Only the password policy fetch should have fired (useEffect on mount), not a password change call
expect(fetchMock).not.toHaveBeenCalledWith('/api/auth/change-password', expect.anything());
});
it('submits successfully and auto-closes after showing success', async () => {
+2
View File
@@ -45,6 +45,8 @@ export const createPrismaMock = () => ({
bookDateConfig: createModelMock(),
bookDateRecommendation: createModelMock(),
bookDateSwipe: createModelMock(),
goodreadsShelf: createModelMock(),
goodreadsBookMapping: createModelMock(),
$queryRaw: vi.fn(),
$disconnect: vi.fn(),
});
+8 -8
View File
@@ -134,14 +134,14 @@ describe('AudibleService', () => {
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) });
.mockResolvedValueOnce({ data: buildListHtml(50, 0) })
.mockResolvedValueOnce({ data: buildListHtml(25, 50) });
const service = new AudibleService();
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
const results = await service.getNewReleases(25);
const results = await service.getNewReleases(75);
expect(results).toHaveLength(15);
expect(results).toHaveLength(75);
expect(delaySpy).toHaveBeenCalledTimes(1);
});
@@ -345,14 +345,14 @@ describe('AudibleService', () => {
it('paginates popular audiobooks across pages', async () => {
configServiceMock.getAudibleRegion.mockResolvedValue('us');
clientMock.get
.mockResolvedValueOnce({ data: buildListHtml(10, 0) })
.mockResolvedValueOnce({ data: buildListHtml(10, 10) });
.mockResolvedValueOnce({ data: buildListHtml(50, 0) })
.mockResolvedValueOnce({ data: buildListHtml(25, 50) });
const service = new AudibleService();
const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined);
const results = await service.getPopularAudiobooks(25);
const results = await service.getPopularAudiobooks(75);
expect(results).toHaveLength(20);
expect(results).toHaveLength(75);
expect(delaySpy).toHaveBeenCalledTimes(1);
});
+41 -4
View File
@@ -153,12 +153,12 @@ describe('QBittorrentService', () => {
expect(progress.state).toBe('paused');
});
it('maps stoppedUP to paused', () => {
it('maps stoppedUP to completed (download finished, stopped on upload side)', () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
const progress = service.getDownloadProgress({
progress: 1.0, downloaded: 1000, size: 1000, dlspeed: 0, eta: 0, state: 'stoppedUP',
} as any);
expect(progress.state).toBe('paused');
expect(progress.state).toBe('completed');
});
});
@@ -180,6 +180,24 @@ describe('QBittorrentService', () => {
});
});
describe('mapState - pausedUP/stoppedUP as completion states (RDT-Client compatibility)', () => {
it('maps pausedUP to completed (download finished, paused on upload side)', () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
const progress = service.getDownloadProgress({
progress: 0.5, downloaded: 0, size: 0, dlspeed: 0, eta: 0, state: 'pausedUP',
} as any);
expect(progress.state).toBe('completed');
});
it('maps pausedDL to paused (download not finished)', () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
const progress = service.getDownloadProgress({
progress: 0.3, downloaded: 300, size: 1000, dlspeed: 0, eta: 0, state: 'pausedDL',
} as any);
expect(progress.state).toBe('paused');
});
});
describe('mapStateToDownloadStatus - forced and new states via getDownload', () => {
it('maps forcedUP to seeding status (triggers completion in monitor)', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
@@ -218,7 +236,7 @@ describe('QBittorrentService', () => {
expect(info!.status).toBe('downloading');
});
it('maps stoppedUP to paused status (qBittorrent v5.x)', async () => {
it('maps stoppedUP to seeding status (qBittorrent v5.x, triggers completion)', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=stopped';
clientMock.get.mockResolvedValueOnce({
@@ -233,7 +251,26 @@ describe('QBittorrentService', () => {
const info = await service.getDownload('abc123');
expect(info).not.toBeNull();
expect(info!.status).toBe('paused');
expect(info!.status).toBe('seeding');
});
it('maps pausedUP to seeding status (RDT-Client: download finished, paused on upload side)', async () => {
const service = new QBittorrentService('http://qb', 'user', 'pass');
(service as any).cookie = 'SID=pausedup';
clientMock.get.mockResolvedValueOnce({
data: [{
hash: 'd5d767f07e5d9027f7f9d9b50b877386dc92b177', name: 'Audiobook', size: 0, progress: 0.5,
dlspeed: 0, upspeed: 0, downloaded: 0, uploaded: 0,
eta: 0, state: 'pausedUP', category: 'readmeabook', tags: '',
save_path: '/data/torrents/readmeabook', content_path: '/data/torrents/readmeabook/Audiobook',
completion_on: 1769135244, added_on: 1769135108,
}],
});
const info = await service.getDownload('d5d767f07e5d9027f7f9d9b50b877386dc92b177');
expect(info).not.toBeNull();
expect(info!.status).toBe('seeding');
});
it('maps stoppedDL to paused status (qBittorrent v5.x)', async () => {
@@ -3,7 +3,7 @@
* Documentation: documentation/backend/services/scheduler.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
@@ -29,8 +29,20 @@ vi.mock('@/lib/services/thumbnail-cache.service', () => ({
}));
describe('processAudibleRefresh', () => {
let origSetTimeout: typeof global.setTimeout;
beforeEach(() => {
vi.clearAllMocks();
origSetTimeout = global.setTimeout;
// Replace setTimeout so the batch cooldown resolves instantly
global.setTimeout = ((fn: (...args: any[]) => void) => {
fn();
return 0 as ReturnType<typeof setTimeout>;
}) as any;
});
afterEach(() => {
global.setTimeout = origSetTimeout;
});
it('refreshes popular and new releases, caching thumbnails', async () => {
@@ -110,5 +122,3 @@ describe('processAudibleRefresh', () => {
await expect(processAudibleRefresh({ jobId: 'job-2' })).rejects.toThrow('DB down');
});
});
@@ -4,6 +4,7 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { NotificationEvent } from '@/lib/constants/notification-events';
const notificationServiceMock = vi.hoisted(() => ({
sendNotification: vi.fn(),
@@ -92,7 +93,7 @@ describe('processSendNotification', () => {
it('processes all event types correctly', async () => {
const { processSendNotification } = await import('@/lib/processors/send-notification.processor');
const events: Array<'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error'> = [
const events: NotificationEvent[] = [
'request_pending_approval',
'request_approved',
'request_available',
+7 -8
View File
@@ -18,6 +18,7 @@ prismaMock.notificationBackend = {
const encryptionMock = vi.hoisted(() => ({
encrypt: vi.fn((value: string) => `enc:${value}`),
decrypt: vi.fn((value: string) => value.replace('enc:', '')),
isEncryptedFormat: vi.fn((value: string) => typeof value === 'string' && value.startsWith('enc:')),
}));
const fetchMock = vi.hoisted(() => vi.fn());
@@ -370,14 +371,12 @@ describe('AppriseProvider', () => {
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
// Use iv:authTag:data format to pass isEncrypted() check
// Note: the value must have exactly 3 colon-separated segments
await service.sendToBackend(
'apprise',
{
serverUrl: 'http://apprise:8000',
urls: 'iv:tag:encryptedUrlsData',
authToken: 'iv:tag:mytoken123',
urls: 'enc:encryptedUrlsData',
authToken: 'enc:mytoken123',
},
{
event: 'request_approved',
@@ -390,16 +389,16 @@ describe('AppriseProvider', () => {
);
// Verify decrypt was called for the sensitive fields
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:encryptedUrlsData');
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:mytoken123');
expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:encryptedUrlsData');
expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:mytoken123');
// Verify the decrypted values reach the fetch call
expect(fetchMock).toHaveBeenCalledTimes(1);
const fetchCall = fetchMock.mock.calls[0];
expect(fetchCall[1].headers['Authorization']).toBe('Bearer iv:tag:mytoken123');
expect(fetchCall[1].headers['Authorization']).toBe('Bearer mytoken123');
const body = JSON.parse(fetchCall[1].body);
expect(body.urls).toBe('iv:tag:encryptedUrlsData');
expect(body.urls).toBe('encryptedUrlsData');
});
it('does not decrypt non-sensitive fields', async () => {
+112
View File
@@ -51,4 +51,116 @@ describe('EncryptionService', () => {
expect(typeof key).toBe('string');
expect(key.length).toBeGreaterThan(40);
});
describe('isEncryptedFormat', () => {
async function createService() {
process.env.CONFIG_ENCRYPTION_KEY = 'c'.repeat(32);
vi.resetModules();
const { EncryptionService } = await import('@/lib/services/encryption.service');
return new EncryptionService();
}
it('returns true for values produced by encrypt()', async () => {
const service = await createService();
const encrypted = service.encrypt('hello world');
expect(service.isEncryptedFormat(encrypted)).toBe(true);
});
it('returns true for various encrypted values (round-trip)', async () => {
const service = await createService();
const testValues = [
'simple',
'tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/',
'slack://tokenA/tokenB/tokenC',
'https://hooks.slack.com/services/T00/B00/xxx',
'a',
'a'.repeat(1000),
'json://user:password@host:8080/path',
];
for (const val of testValues) {
const encrypted = service.encrypt(val);
expect(service.isEncryptedFormat(encrypted)).toBe(true);
expect(service.decrypt(encrypted)).toBe(val);
}
});
it('returns false for Telegram notification URLs (the reported bug)', async () => {
const service = await createService();
// This URL has exactly 3 colon-separated parts, which fooled the old check
expect(service.isEncryptedFormat(
'tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/'
)).toBe(false);
});
it('returns false for common notification URL schemes', async () => {
const service = await createService();
const urls = [
'slack://tokenA/tokenB/tokenC',
'discord://webhook_id/webhook_token',
'mailto://user:pass@gmail.com',
'json://user:pass@hostname',
'https://hooks.slack.com/services/T00/B00/xxx',
'gotify://hostname/token',
'ntfy://topic',
'tgram://bot_token:chat_id/',
];
for (const url of urls) {
expect(service.isEncryptedFormat(url)).toBe(false);
}
});
it('returns false for non-string values', async () => {
const service = await createService();
expect(service.isEncryptedFormat(null as any)).toBe(false);
expect(service.isEncryptedFormat(undefined as any)).toBe(false);
expect(service.isEncryptedFormat(123 as any)).toBe(false);
expect(service.isEncryptedFormat({} as any)).toBe(false);
});
it('returns false for strings with wrong number of colon parts', async () => {
const service = await createService();
expect(service.isEncryptedFormat('no-colons-at-all')).toBe(false);
expect(service.isEncryptedFormat('one:part')).toBe(false);
expect(service.isEncryptedFormat('a:b:c:d')).toBe(false);
});
it('returns false for 3-part strings with invalid base64', async () => {
const service = await createService();
// Contains characters not in base64 alphabet
expect(service.isEncryptedFormat('not base64!:also not!:data')).toBe(false);
expect(service.isEncryptedFormat('//invalid:##bad:data')).toBe(false);
});
it('returns false for 3-part base64 strings with wrong decoded lengths', async () => {
const service = await createService();
// Valid base64, but wrong byte lengths (not 16 bytes each)
const shortIv = Buffer.from('short').toString('base64'); // 5 bytes
const shortTag = Buffer.from('alsoshort').toString('base64'); // 9 bytes
const data = Buffer.from('somedata').toString('base64');
expect(service.isEncryptedFormat(`${shortIv}:${shortTag}:${data}`)).toBe(false);
});
it('returns false for empty string', async () => {
const service = await createService();
expect(service.isEncryptedFormat('')).toBe(false);
});
it('returns false for 3-part string with empty segments', async () => {
const service = await createService();
expect(service.isEncryptedFormat('::data')).toBe(false);
expect(service.isEncryptedFormat('iv::data')).toBe(false);
expect(service.isEncryptedFormat('iv:tag:')).toBe(false);
});
});
});
@@ -4,6 +4,7 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { NotificationEvent } from '@/lib/constants/notification-events';
describe('JobQueueService - Notification Integration', () => {
beforeEach(() => {
@@ -50,7 +51,7 @@ describe('JobQueueService - Notification Integration', () => {
});
it('handles all event types', () => {
const events: Array<'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error'> = [
const events: NotificationEvent[] = [
'request_pending_approval',
'request_approved',
'request_available',
+6
View File
@@ -21,6 +21,7 @@ const processorsMock = vi.hoisted(() => ({
processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'),
processRetryFailedImports: vi.fn().mockResolvedValue('ok'),
processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'),
processSyncGoodreadsShelves: vi.fn().mockResolvedValue('ok'),
// Ebook processors
processSearchEbook: vi.fn().mockResolvedValue('ok'),
processStartDirectDownload: vi.fn().mockResolvedValue('ok'),
@@ -115,6 +116,10 @@ vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({
processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents,
}));
vi.mock('@/lib/processors/sync-goodreads-shelves.processor', () => ({
processSyncGoodreadsShelves: processorsMock.processSyncGoodreadsShelves,
}));
// Ebook processors
vi.mock('@/lib/processors/search-ebook.processor', () => ({
processSearchEbook: processorsMock.processSearchEbook,
@@ -559,6 +564,7 @@ describe('JobQueueService', () => {
expect(processorsMock.processRetryMissingTorrents).toHaveBeenCalled();
expect(processorsMock.processRetryFailedImports).toHaveBeenCalled();
expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled();
expect(processorsMock.processSyncGoodreadsShelves).toHaveBeenCalled();
});
it('returns repeatable jobs from the queue', async () => {
+89 -4
View File
@@ -18,6 +18,7 @@ prismaMock.notificationBackend = {
const encryptionMock = vi.hoisted(() => ({
encrypt: vi.fn((value: string) => `enc:${value}`),
decrypt: vi.fn((value: string) => value.replace('enc:', '')),
isEncryptedFormat: vi.fn((value: string) => typeof value === 'string' && value.startsWith('enc:')),
}));
const fetchMock = vi.hoisted(() => vi.fn());
@@ -196,10 +197,9 @@ describe('NotificationService', () => {
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
// Use iv:authTag:data format to pass isEncrypted() check
await service.sendToBackend(
'pushover',
{ userKey: 'iv:tag:user123', appToken: 'iv:tag:app456', priority: 1 },
{ userKey: 'enc:user123', appToken: 'enc:app456', priority: 1 },
{
event: 'request_approved',
requestId: 'req-1',
@@ -210,8 +210,8 @@ describe('NotificationService', () => {
}
);
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:user123');
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:app456');
expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:user123');
expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:app456');
expect(fetchMock).toHaveBeenCalled();
});
@@ -519,6 +519,91 @@ describe('NotificationService', () => {
});
});
describe('reEncryptUnprotectedBackends', () => {
it('re-encrypts plaintext sensitive fields stored due to isEncrypted bug', async () => {
// Simulate a backend with a Telegram URL stored as plaintext (the bug)
prismaMock.notificationBackend.findMany.mockResolvedValue([
{
id: 'backend-1',
type: 'apprise',
name: 'Telegram via Apprise',
config: {
serverUrl: 'http://apprise:8000',
urls: 'tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/',
},
events: ['request_available'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
prismaMock.notificationBackend.update.mockResolvedValue({} as any);
// Mock isEncryptedFormat to return false for the plaintext URL
encryptionMock.isEncryptedFormat.mockImplementation(
(value: string) => typeof value === 'string' && value.startsWith('enc:')
);
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
const fixed = await service.reEncryptUnprotectedBackends();
expect(fixed).toBe(1);
expect(encryptionMock.encrypt).toHaveBeenCalledWith(
'tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/'
);
expect(prismaMock.notificationBackend.update).toHaveBeenCalledWith({
where: { id: 'backend-1' },
data: {
config: {
serverUrl: 'http://apprise:8000',
urls: 'enc:tgram://1234567890:PLPe1Hh-VhbRC3MoT5QngwkPHoMTD/-100181291455/',
},
},
});
});
it('skips backends with already-encrypted fields', async () => {
prismaMock.notificationBackend.findMany.mockResolvedValue([
{
id: 'backend-1',
type: 'discord',
name: 'Discord',
config: { webhookUrl: 'enc:https://discord.com/webhook', username: 'Bot' },
events: ['request_available'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
encryptionMock.isEncryptedFormat.mockImplementation(
(value: string) => typeof value === 'string' && value.startsWith('enc:')
);
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
const fixed = await service.reEncryptUnprotectedBackends();
expect(fixed).toBe(0);
expect(prismaMock.notificationBackend.update).not.toHaveBeenCalled();
});
it('returns 0 when no backends exist', async () => {
prismaMock.notificationBackend.findMany.mockResolvedValue([]);
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
const fixed = await service.reEncryptUnprotectedBackends();
expect(fixed).toBe(0);
});
});
describe('maskConfig', () => {
it('masks sensitive Discord config values', async () => {
const { NotificationService } = await import('@/lib/services/notification');
+8 -7
View File
@@ -18,6 +18,7 @@ prismaMock.notificationBackend = {
const encryptionMock = vi.hoisted(() => ({
encrypt: vi.fn((value: string) => `enc:${value}`),
decrypt: vi.fn((value: string) => value.replace('enc:', '')),
isEncryptedFormat: vi.fn((value: string) => typeof value === 'string' && value.startsWith('enc:')),
}));
const fetchMock = vi.hoisted(() => vi.fn());
@@ -65,7 +66,8 @@ describe('NtfyProvider', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
const fetchCall = fetchMock.mock.calls[0];
expect(fetchCall[0]).toBe('https://ntfy.example.com/audiobooks');
// ntfy JSON publishing: POST to base server URL, topic is in JSON body
expect(fetchCall[0]).toBe('https://ntfy.example.com');
expect(fetchCall[1].method).toBe('POST');
expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
expect(fetchCall[1].headers['Authorization']).toBe('Bearer tk_mytoken123');
@@ -104,7 +106,7 @@ describe('NtfyProvider', () => {
);
const fetchCall = fetchMock.mock.calls[0];
expect(fetchCall[0]).toBe('https://ntfy.sh/audiobooks');
expect(fetchCall[0]).toBe('https://ntfy.sh');
});
it('does not include Authorization header when accessToken is not provided', async () => {
@@ -210,7 +212,7 @@ describe('NtfyProvider', () => {
);
const fetchCall = fetchMock.mock.calls[0];
expect(fetchCall[0]).toBe('https://ntfy.example.com/audiobooks');
expect(fetchCall[0]).toBe('https://ntfy.example.com');
});
it('throws descriptive error when API returns non-OK response', async () => {
@@ -275,13 +277,12 @@ describe('NtfyProvider', () => {
const { NotificationService } = await import('@/lib/services/notification');
const service = new NotificationService();
// Use iv:authTag:data format to pass isEncrypted() check
await service.sendToBackend(
'ntfy',
{
serverUrl: 'https://ntfy.example.com',
topic: 'audiobooks',
accessToken: 'iv:tag:tk_mytoken123',
accessToken: 'enc:tk_mytoken123',
},
{
event: 'request_approved',
@@ -294,12 +295,12 @@ describe('NtfyProvider', () => {
);
// Verify decrypt was called for the sensitive field
expect(encryptionMock.decrypt).toHaveBeenCalledWith('iv:tag:tk_mytoken123');
expect(encryptionMock.decrypt).toHaveBeenCalledWith('enc:tk_mytoken123');
// Verify the decrypted value reaches the fetch call
expect(fetchMock).toHaveBeenCalledTimes(1);
const fetchCall = fetchMock.mock.calls[0];
expect(fetchCall[1].headers['Authorization']).toBe('Bearer iv:tag:tk_mytoken123');
expect(fetchCall[1].headers['Authorization']).toBe('Bearer tk_mytoken123');
});
it('does not decrypt non-sensitive fields', async () => {
+11 -1
View File
@@ -18,6 +18,7 @@ const jobQueueMock = vi.hoisted(() => ({
addRetryFailedImportsJob: vi.fn(),
addCleanupSeededTorrentsJob: vi.fn(),
addMonitorRssFeedsJob: vi.fn(),
addSyncGoodreadsShelvesJob: vi.fn(),
}));
const configServiceMock = vi.hoisted(() => ({
@@ -25,6 +26,10 @@ const configServiceMock = vi.hoisted(() => ({
getMany: vi.fn(),
}));
const notificationServiceMock = vi.hoisted(() => ({
reEncryptUnprotectedBackends: vi.fn().mockResolvedValue(0),
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
@@ -33,6 +38,10 @@ vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
vi.mock('@/lib/services/notification', () => ({
getNotificationService: () => notificationServiceMock,
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
@@ -69,7 +78,7 @@ describe('SchedulerService', () => {
const service = new SchedulerService();
await service.start();
expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(7);
expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(8);
expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
'audible_refresh',
{ scheduledJobId: 'job-1' },
@@ -280,6 +289,7 @@ describe('SchedulerService', () => {
['retry_failed_imports', 'addRetryFailedImportsJob'],
['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'],
['monitor_rss_feeds', 'addMonitorRssFeedsJob'],
['sync_goodreads_shelves', 'addSyncGoodreadsShelvesJob'],
])('triggers %s jobs with job queue', async (type, queueMethod) => {
prismaMock.scheduledJob.findUnique.mockResolvedValue({
id: 'job-type',
+147
View File
@@ -0,0 +1,147 @@
/**
* Component: Scrape Resilience Utility Tests
* Documentation: documentation/integrations/audible.md
*/
import { describe, expect, it } from 'vitest';
import {
pickUserAgent,
getBrowserHeaders,
jitteredBackoff,
randomDelay,
AdaptivePacer,
} from '@/lib/utils/scrape-resilience';
describe('pickUserAgent', () => {
it('returns a string containing Mozilla', () => {
const ua = pickUserAgent();
expect(typeof ua).toBe('string');
expect(ua).toContain('Mozilla');
});
it('returns values from the known pool', () => {
const seen = new Set<string>();
for (let i = 0; i < 100; i++) {
seen.add(pickUserAgent());
}
// Should have picked at least 2 different UAs over 100 draws
expect(seen.size).toBeGreaterThanOrEqual(2);
for (const ua of seen) {
expect(ua).toContain('Mozilla/5.0');
}
});
});
describe('getBrowserHeaders', () => {
it('includes all expected header keys', () => {
const headers = getBrowserHeaders('TestUA/1.0');
expect(headers['User-Agent']).toBe('TestUA/1.0');
expect(headers['Accept']).toBeDefined();
expect(headers['Accept-Language']).toBeDefined();
expect(headers['Accept-Encoding']).toBeDefined();
expect(headers['Connection']).toBeDefined();
expect(headers['Sec-Fetch-Site']).toBeDefined();
expect(headers['Sec-Fetch-Mode']).toBeDefined();
expect(headers['Sec-Fetch-Dest']).toBeDefined();
expect(headers['Sec-Fetch-User']).toBeDefined();
expect(headers['Upgrade-Insecure-Requests']).toBeDefined();
});
});
describe('jitteredBackoff', () => {
it('returns values within the expected jitter range', () => {
for (let attempt = 0; attempt < 5; attempt++) {
for (let i = 0; i < 50; i++) {
const value = jitteredBackoff(attempt, 1000);
const base = Math.pow(2, attempt) * 1000;
// Jitter range is 0.5x 1.5x
expect(value).toBeGreaterThanOrEqual(Math.round(base * 0.5));
expect(value).toBeLessThanOrEqual(Math.round(base * 1.5));
}
}
});
it('uses custom base ms', () => {
const value = jitteredBackoff(0, 500);
// attempt=0: 1 * 500 * [0.5..1.5] → [250..750]
expect(value).toBeGreaterThanOrEqual(250);
expect(value).toBeLessThanOrEqual(750);
});
});
describe('randomDelay', () => {
it('returns values within bounds', () => {
for (let i = 0; i < 100; i++) {
const val = randomDelay(100, 200);
expect(val).toBeGreaterThanOrEqual(100);
expect(val).toBeLessThanOrEqual(200);
}
});
});
describe('AdaptivePacer', () => {
it('returns base delay range when no retries needed', () => {
const pacer = new AdaptivePacer();
for (let i = 0; i < 50; i++) {
const delay = pacer.reportPageResult({ retriesUsed: 0, encountered503: false });
expect(delay).toBeGreaterThanOrEqual(2000);
expect(delay).toBeLessThanOrEqual(4000);
}
});
it('increases delay when retries occurred', () => {
const pacer = new AdaptivePacer();
// First retry page: consecutiveRetryPages becomes 1, multiplier = 1.5
const delay = pacer.reportPageResult({ retriesUsed: 2, encountered503: true });
// Range: [2000*1.5, 4000*1.5] = [3000, 6000]
expect(delay).toBeGreaterThanOrEqual(3000);
expect(delay).toBeLessThanOrEqual(6000);
});
it('triggers circuit breaker after 3 consecutive retry pages', () => {
const pacer = new AdaptivePacer();
const retryMeta = { retriesUsed: 1, encountered503: true };
pacer.reportPageResult(retryMeta); // consecutive = 1
pacer.reportPageResult(retryMeta); // consecutive = 2
const cooldown = pacer.reportPageResult(retryMeta); // consecutive = 3 → circuit breaker
expect(cooldown).toBeGreaterThanOrEqual(45000);
expect(cooldown).toBeLessThanOrEqual(60000);
});
it('recovers gradually after successful pages', () => {
const pacer = new AdaptivePacer();
const retryMeta = { retriesUsed: 1, encountered503: true };
const successMeta = { retriesUsed: 0, encountered503: false };
// Build up to 2 consecutive retries
pacer.reportPageResult(retryMeta); // consecutive = 1
pacer.reportPageResult(retryMeta); // consecutive = 2
// Success decrements: consecutive goes from 2 → 1
const delay = pacer.reportPageResult(successMeta);
expect(delay).toBeGreaterThanOrEqual(2000);
expect(delay).toBeLessThanOrEqual(4000);
// Another success: consecutive goes from 1 → 0
const delay2 = pacer.reportPageResult(successMeta);
expect(delay2).toBeGreaterThanOrEqual(2000);
expect(delay2).toBeLessThanOrEqual(4000);
});
it('resets state', () => {
const pacer = new AdaptivePacer();
const retryMeta = { retriesUsed: 1, encountered503: true };
pacer.reportPageResult(retryMeta); // consecutive = 1
pacer.reportPageResult(retryMeta); // consecutive = 2
pacer.reset();
// After reset, should be back to base range behavior for retries
const delay = pacer.reportPageResult(retryMeta);
// consecutive = 1 again, multiplier = 1.5 → [3000, 6000]
expect(delay).toBeGreaterThanOrEqual(3000);
expect(delay).toBeLessThanOrEqual(6000);
});
});