mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -45,6 +45,8 @@ export const createPrismaMock = () => ({
|
||||
bookDateConfig: createModelMock(),
|
||||
bookDateRecommendation: createModelMock(),
|
||||
bookDateSwipe: createModelMock(),
|
||||
goodreadsShelf: createModelMock(),
|
||||
goodreadsBookMapping: createModelMock(),
|
||||
$queryRaw: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user