mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add skip-unreleased auto-search feature
Introduce an indexer-wide option to skip automatic searches for books with future release dates (config key: `indexer.skip_unreleased`, default ON). Adds a GET/PUT admin API for indexer options, a UI toggle on the Indexers settings tab (persisted on save), and persistence of a request-level releaseDate in the Prisma schema. Adds a new request status `awaiting_release` and wires it through constants, UI components (StatusBadge, RequestCard, RecentRequestsTable, Audiobook card/modal, RequestActions), API request flows (bookdate swipe, request creation, manual search, request PATCHs, request listing groups), and services. Implements a pure release-date utility (isUnreleased / shouldSkipAutoSearch) and updates background processors: monitor-rss-feeds (skip matches but do not mutate status), retry-missing-torrents (drives bidirectional transitions between awaiting_search and awaiting_release and queues searches when appropriate), and request-creator/bookdate swipe (gate initial auto-search). Adds tests for the swipe gate and other related test updates. Logs transitions and gate decisions for observability.
This commit is contained in:
@@ -32,19 +32,28 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
findPlexMatch: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
// Mock AudibleService
|
||||
// Mock AudibleService (default = no Audnexus data)
|
||||
const audibleServiceMock = vi.hoisted(() => ({
|
||||
getAudiobookDetails: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
getAudibleService: () => ({
|
||||
getAudiobookDetails: vi.fn().mockResolvedValue(null),
|
||||
getAudibleService: () => audibleServiceMock,
|
||||
}));
|
||||
|
||||
// Mock job queue (shared across tests so we can assert addSearchJob calls)
|
||||
const jobQueueAddSearchJob = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const jobQueueAddNotificationJob = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => ({
|
||||
addSearchJob: jobQueueAddSearchJob,
|
||||
addNotificationJob: jobQueueAddNotificationJob,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock job queue
|
||||
vi.mock('@/lib/services/job-queue.service', () => ({
|
||||
getJobQueueService: () => ({
|
||||
addSearchJob: vi.fn().mockResolvedValue(undefined),
|
||||
addNotificationJob: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
// Mock config service for indexer.skip_unreleased setting
|
||||
const configServiceGet = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/services/config.service', () => ({
|
||||
getConfigService: () => ({ get: configServiceGet }),
|
||||
}));
|
||||
|
||||
// Mock getSiblingAsins from works.service
|
||||
@@ -68,6 +77,10 @@ describe('createRequestForUser — ignore list', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Restore mock return values cleared by clearAllMocks
|
||||
jobQueueAddSearchJob.mockResolvedValue(undefined);
|
||||
jobQueueAddNotificationJob.mockResolvedValue(undefined);
|
||||
|
||||
// Default: no existing requests, no library matches
|
||||
prismaMock.request.findFirst.mockResolvedValue(null);
|
||||
prismaMock.audiobook.findFirst.mockResolvedValue(null);
|
||||
@@ -97,6 +110,10 @@ describe('createRequestForUser — ignore list', () => {
|
||||
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
|
||||
mockGetSiblingAsins.mockResolvedValue(new Map());
|
||||
mockSeedAsin.mockResolvedValue(undefined);
|
||||
|
||||
// Default Audnexus + config behaviour
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValue(null);
|
||||
configServiceGet.mockResolvedValue(null); // default → setting ON
|
||||
});
|
||||
|
||||
it('blocks auto-request when ASIN is directly ignored', async () => {
|
||||
@@ -198,3 +215,114 @@ describe('createRequestForUser — ignore list', () => {
|
||||
expect(prismaMock.ignoredAudiobook.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRequestForUser — release-date gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
jobQueueAddSearchJob.mockResolvedValue(undefined);
|
||||
jobQueueAddNotificationJob.mockResolvedValue(undefined);
|
||||
prismaMock.request.findFirst.mockResolvedValue(null);
|
||||
prismaMock.audiobook.findFirst.mockResolvedValue(null);
|
||||
prismaMock.audiobook.create.mockResolvedValue({
|
||||
id: 'audiobook-1',
|
||||
audibleAsin: TEST_AUDIOBOOK.asin,
|
||||
title: TEST_AUDIOBOOK.title,
|
||||
author: TEST_AUDIOBOOK.author,
|
||||
narrator: null,
|
||||
});
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
role: 'user',
|
||||
autoApproveRequests: true,
|
||||
plexUsername: 'testuser',
|
||||
});
|
||||
prismaMock.ignoredAudiobook.findUnique.mockResolvedValue(null);
|
||||
prismaMock.ignoredAudiobook.findFirst.mockResolvedValue(null);
|
||||
mockGetSiblingAsins.mockResolvedValue(new Map());
|
||||
mockSeedAsin.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('creates request in awaiting_release with no search when book is unreleased and setting ON', async () => {
|
||||
const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValue({ releaseDate: future });
|
||||
configServiceGet.mockResolvedValue(null); // default → ON
|
||||
|
||||
prismaMock.request.create.mockResolvedValue({
|
||||
id: 'request-future-on',
|
||||
userId: TEST_USER_ID,
|
||||
audiobookId: 'audiobook-1',
|
||||
status: 'awaiting_release',
|
||||
audiobook: { id: 'audiobook-1', title: 'Test Book' },
|
||||
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
|
||||
});
|
||||
|
||||
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'awaiting_release',
|
||||
releaseDate: expect.any(Date),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(jobQueueAddSearchJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates pending request and runs search when book is already released and setting ON', async () => {
|
||||
const past = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString();
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValue({ releaseDate: past });
|
||||
configServiceGet.mockResolvedValue('true');
|
||||
|
||||
prismaMock.request.create.mockResolvedValue({
|
||||
id: 'request-past-on',
|
||||
userId: TEST_USER_ID,
|
||||
audiobookId: 'audiobook-1',
|
||||
status: 'pending',
|
||||
audiobook: { id: 'audiobook-1', title: 'Test Book' },
|
||||
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
|
||||
});
|
||||
|
||||
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'pending',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(jobQueueAddSearchJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates pending request and runs search when book is unreleased but setting OFF', async () => {
|
||||
const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
audibleServiceMock.getAudiobookDetails.mockResolvedValue({ releaseDate: future });
|
||||
configServiceGet.mockResolvedValue('false');
|
||||
|
||||
prismaMock.request.create.mockResolvedValue({
|
||||
id: 'request-future-off',
|
||||
userId: TEST_USER_ID,
|
||||
audiobookId: 'audiobook-1',
|
||||
status: 'pending',
|
||||
audiobook: { id: 'audiobook-1', title: 'Test Book' },
|
||||
user: { id: TEST_USER_ID, plexUsername: 'testuser' },
|
||||
});
|
||||
|
||||
const { createRequestForUser } = await import('@/lib/services/request-creator.service');
|
||||
const result = await createRequestForUser(TEST_USER_ID, TEST_AUDIOBOOK);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prismaMock.request.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'pending',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(jobQueueAddSearchJob).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user