Files
ReadMeABook/tests/processors/find-missing-ebooks.processor.test.ts
kikootwo 06195e6570 Add find_missing_ebooks scheduled job
Introduce a safety-net scheduled job that scans completed audiobooks and auto-triggers ebook fetches for missing companions. Changes include:

- New Prisma migration + schema field: requests.ebook_auto_retry_count (nullable) to track lifetime auto-retries.
- New processor: src/lib/processors/find-missing-ebooks.processor.ts implementing the scan (limit 50), gating by ebook_auto_grab_enabled and source flags, creating ebook child requests or retrying failed ones up to a cap of 5, using transactions for race-safety and rolling back the counter if enqueue fails.
- Job queue integration: add job type, payload, processor registration, and addFindMissingEbooksJob helper.
- Scheduler integration: register the scheduled job (daily midnight) and trigger path.
- Documentation updates: backend scheduler and ebook-sidecar docs describing behavior and limits.
- Tests: add comprehensive unit tests for the processor and update scheduler tests and job-queue test helper.

This implements automated recovery for missing ebook companions while keeping the retry counter processor-private and ensuring safe concurrency handling.
2026-05-17 18:22:55 -04:00

473 lines
15 KiB
TypeScript

/**
* Component: Find Missing Ebooks 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();
const configMock = vi.hoisted(() => ({ get: 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,
}));
type CandidateRow = {
parent_request_id: string;
user_id: string;
audiobook_id: string;
custom_search_terms: string | null;
audiobook_title: string;
audiobook_author: string;
audible_asin: string | null;
ebook_request_id: string | null;
ebook_status: string | null;
ebook_auto_retry_count: number | null;
};
const baseRow = (overrides: Partial<CandidateRow> = {}): CandidateRow => ({
parent_request_id: 'parent-1',
user_id: 'user-1',
audiobook_id: 'audio-1',
custom_search_terms: null,
audiobook_title: 'Test Book',
audiobook_author: 'Test Author',
audible_asin: 'ASIN0001',
ebook_request_id: null,
ebook_status: null,
ebook_auto_retry_count: null,
...overrides,
});
/**
* Default: all gates pass (auto-grab default ON when null; Anna's enabled).
* Tests that want a different gate state can override before calling.
*/
const installDefaultGates = () => {
configMock.get.mockImplementation(async (key: string) => {
switch (key) {
case 'ebook_auto_grab_enabled':
return null; // null/absent => ON
case 'ebook_annas_archive_enabled':
return 'true';
case 'ebook_indexer_search_enabled':
return 'false';
case 'ebook_sidecar_enabled':
return null;
default:
return null;
}
});
};
beforeEach(() => {
vi.clearAllMocks();
installDefaultGates();
// Default: $transaction runs the callback against the prismaMock surface.
prismaMock.$transaction.mockImplementation(async (fn: any) => fn(prismaMock));
// Default: each create call returns a stable ebook request id.
prismaMock.request.create.mockImplementation(async (args: any) => ({
id: 'new-ebook-1',
...args.data,
}));
prismaMock.request.update.mockResolvedValue({});
});
describe('processFindMissingEbooks — gating', () => {
it('returns zeros when auto-grab is disabled (explicit false)', async () => {
configMock.get.mockImplementation(async (key: string) =>
key === 'ebook_auto_grab_enabled' ? 'false' : null
);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-1' });
expect(result).toMatchObject({
success: true,
scanned: 0,
gapsFound: 0,
triggered: 0,
created: 0,
retried: 0,
skippedInFlight: 0,
skippedCancelled: 0,
skippedCapHit: 0,
});
expect(prismaMock.$queryRaw).not.toHaveBeenCalled();
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
});
it('treats auto-grab unset (null) as ON and proceeds to source check', async () => {
configMock.get.mockImplementation(async (key: string) => {
switch (key) {
case 'ebook_auto_grab_enabled':
return null;
case 'ebook_annas_archive_enabled':
return 'true';
default:
return null;
}
});
prismaMock.$queryRaw.mockResolvedValue([]);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-2' });
expect(result.scanned).toBe(0);
expect(prismaMock.$queryRaw).toHaveBeenCalledTimes(1);
});
it('returns zeros when both new source keys disabled AND no legacy key', async () => {
configMock.get.mockImplementation(async (key: string) => {
switch (key) {
case 'ebook_auto_grab_enabled':
return null;
case 'ebook_annas_archive_enabled':
return 'false';
case 'ebook_indexer_search_enabled':
return 'false';
case 'ebook_sidecar_enabled':
return null;
default:
return null;
}
});
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-3' });
expect(result.scanned).toBe(0);
expect(prismaMock.$queryRaw).not.toHaveBeenCalled();
});
it('legacy ebook_sidecar_enabled=true (with new keys absent) passes the source gate', async () => {
configMock.get.mockImplementation(async (key: string) => {
switch (key) {
case 'ebook_auto_grab_enabled':
return null;
case 'ebook_annas_archive_enabled':
return null;
case 'ebook_indexer_search_enabled':
return null;
case 'ebook_sidecar_enabled':
return 'true';
default:
return null;
}
});
prismaMock.$queryRaw.mockResolvedValue([]);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-4' });
expect(result.scanned).toBe(0);
expect(prismaMock.$queryRaw).toHaveBeenCalledTimes(1);
});
});
describe('processFindMissingEbooks — fresh-gap creation', () => {
it('creates a new ebook request when no live ebook child exists (audiobook downloaded)', async () => {
prismaMock.$queryRaw.mockResolvedValue([
baseRow({
parent_request_id: 'parent-fresh',
user_id: 'user-x',
audiobook_id: 'audio-x',
audiobook_title: 'Fresh Book',
audiobook_author: 'Some Author',
audible_asin: 'B09ABCDEFG',
custom_search_terms: 'cst',
}),
]);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-5' });
expect(prismaMock.request.create).toHaveBeenCalledWith({
data: {
userId: 'user-x',
audiobookId: 'audio-x',
type: 'ebook',
parentRequestId: 'parent-fresh',
status: 'pending',
progress: 0,
customSearchTerms: 'cst',
ebookAutoRetryCount: 1,
},
});
expect(jobQueueMock.addSearchEbookJob).toHaveBeenCalledWith(
'new-ebook-1',
expect.objectContaining({
id: 'audio-x',
title: 'Fresh Book',
author: 'Some Author',
asin: 'B09ABCDEFG',
})
);
expect(result).toMatchObject({
scanned: 1,
gapsFound: 1,
triggered: 1,
created: 1,
retried: 0,
skippedInFlight: 0,
skippedCancelled: 0,
skippedCapHit: 0,
});
});
it('also creates for audiobook in `available` state (both statuses in scope)', async () => {
// The query is responsible for filtering by status; here we just confirm
// that the processor doesn't add a second status guard in JS that would
// reject a row coming back from SQL.
prismaMock.$queryRaw.mockResolvedValue([
baseRow({
parent_request_id: 'parent-available',
audiobook_title: 'Available Book',
}),
]);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-6' });
expect(result.created).toBe(1);
expect(prismaMock.request.create).toHaveBeenCalled();
});
it('omits asin when audiobook has no audibleAsin', async () => {
prismaMock.$queryRaw.mockResolvedValue([baseRow({ audible_asin: null })]);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
await processFindMissingEbooks({ jobId: 'job-6b' });
expect(jobQueueMock.addSearchEbookJob).toHaveBeenCalledWith(
'new-ebook-1',
expect.objectContaining({ asin: undefined })
);
});
});
describe('processFindMissingEbooks — branch skips', () => {
it('skips when most-recent ebook child is already downloaded (defensive)', async () => {
prismaMock.$queryRaw.mockResolvedValue([
baseRow({
ebook_request_id: 'ebook-1',
ebook_status: 'downloaded',
ebook_auto_retry_count: 0,
}),
]);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-7' });
expect(prismaMock.request.create).not.toHaveBeenCalled();
expect(prismaMock.request.update).not.toHaveBeenCalled();
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
// skipped-has-companion is intentionally not surfaced as its own counter —
// admin can derive from scanned - gapsFound - skippedInFlight - skippedCancelled - skippedCapHit.
expect(result).toMatchObject({
scanned: 1,
gapsFound: 0,
triggered: 0,
skippedInFlight: 0,
skippedCancelled: 0,
skippedCapHit: 0,
});
});
it.each([
'pending',
'awaiting_approval',
'searching',
'downloading',
'processing',
'awaiting_search',
'awaiting_release',
])('skips when most-recent ebook child status is in-flight: %s', async (status) => {
prismaMock.$queryRaw.mockResolvedValue([
baseRow({ ebook_request_id: 'ebook-1', ebook_status: status }),
]);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: `job-inflight-${status}` });
expect(prismaMock.request.create).not.toHaveBeenCalled();
expect(prismaMock.request.update).not.toHaveBeenCalled();
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
expect(result.skippedInFlight).toBe(1);
expect(result.gapsFound).toBe(0);
});
it('skips when most-recent ebook child status is cancelled', async () => {
prismaMock.$queryRaw.mockResolvedValue([
baseRow({ ebook_request_id: 'ebook-1', ebook_status: 'cancelled' }),
]);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-cancelled' });
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
expect(result.skippedCancelled).toBe(1);
});
});
describe('processFindMissingEbooks — retry path', () => {
it('retries a failed ebook child with counter < cap, increments counter', async () => {
prismaMock.$queryRaw.mockResolvedValue([
baseRow({
ebook_request_id: 'ebook-fail-1',
ebook_status: 'failed',
ebook_auto_retry_count: 3,
}),
]);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-retry' });
expect(prismaMock.request.update).toHaveBeenCalledWith({
where: { id: 'ebook-fail-1' },
data: {
status: 'pending',
progress: 0,
errorMessage: null,
ebookAutoRetryCount: 4,
},
});
expect(jobQueueMock.addSearchEbookJob).toHaveBeenCalledWith(
'ebook-fail-1',
expect.objectContaining({ id: 'audio-1' })
);
expect(result).toMatchObject({
retried: 1,
created: 0,
gapsFound: 1,
triggered: 1,
skippedCapHit: 0,
});
});
it('skips a warn ebook child whose counter is at the cap (5)', async () => {
prismaMock.$queryRaw.mockResolvedValue([
baseRow({
ebook_request_id: 'ebook-cap',
ebook_status: 'warn',
ebook_auto_retry_count: 5,
}),
]);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-cap' });
expect(prismaMock.request.update).not.toHaveBeenCalled();
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
expect(result.skippedCapHit).toBe(1);
expect(result.retried).toBe(0);
});
it('retries a failed ebook child with null counter, sets counter to 1', async () => {
prismaMock.$queryRaw.mockResolvedValue([
baseRow({
ebook_request_id: 'ebook-null-counter',
ebook_status: 'failed',
ebook_auto_retry_count: null,
}),
]);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-null' });
expect(prismaMock.request.update).toHaveBeenCalledWith({
where: { id: 'ebook-null-counter' },
data: {
status: 'pending',
progress: 0,
errorMessage: null,
ebookAutoRetryCount: 1,
},
});
expect(result.retried).toBe(1);
});
});
describe('processFindMissingEbooks — error isolation', () => {
it('rolls back the counter when addSearchEbookJob throws, then continues with next candidate', async () => {
// Two candidates: first one's enqueue will throw, second should still process.
prismaMock.$queryRaw.mockResolvedValue([
baseRow({
parent_request_id: 'parent-throw',
audiobook_id: 'audio-throw',
ebook_request_id: 'ebook-throw',
ebook_status: 'failed',
ebook_auto_retry_count: 2,
}),
baseRow({
parent_request_id: 'parent-ok',
audiobook_id: 'audio-ok',
ebook_request_id: 'ebook-ok',
ebook_status: 'failed',
ebook_auto_retry_count: 0,
}),
]);
jobQueueMock.addSearchEbookJob
.mockRejectedValueOnce(new Error('queue blew up'))
.mockResolvedValueOnce('bull-job-id');
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-throw' });
// Counter rolled back on the throwing candidate:
expect(prismaMock.request.update).toHaveBeenCalledWith({
where: { id: 'ebook-throw' },
data: { ebookAutoRetryCount: { decrement: 1 } },
});
// Second candidate still processed:
expect(prismaMock.request.update).toHaveBeenCalledWith({
where: { id: 'ebook-ok' },
data: {
status: 'pending',
progress: 0,
errorMessage: null,
ebookAutoRetryCount: 1,
},
});
// gapsFound counts both attempted gaps; only the second succeeds in being triggered.
expect(result.gapsFound).toBe(2);
expect(result.retried).toBe(1);
expect(result.created).toBe(0);
});
});
describe('processFindMissingEbooks — return shape', () => {
it('exposes all observable counters in the result', async () => {
prismaMock.$queryRaw.mockResolvedValue([]);
const { processFindMissingEbooks } = await import('@/lib/processors/find-missing-ebooks.processor');
const result = await processFindMissingEbooks({ jobId: 'job-shape' });
expect(result).toEqual(
expect.objectContaining({
success: true,
scanned: 0,
gapsFound: 0,
triggered: 0,
created: 0,
retried: 0,
skippedInFlight: 0,
skippedCancelled: 0,
skippedCapHit: 0,
})
);
});
});