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:
kikootwo
2026-05-15 15:35:01 -04:00
parent 5f62ba7146
commit 6f8ac86a43
37 changed files with 1289 additions and 77 deletions
+211
View File
@@ -0,0 +1,211 @@
/**
* Component: BookDate Swipe Release-Date Gate Tests
* Documentation: documentation/features/bookdate-prd.md
*
* Narrow coverage for the release-date gate on right-swipe request creation.
* Broader swipe behavior is covered in tests/api/bookdate.routes.test.ts.
*/
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 audibleServiceMock = vi.hoisted(() => ({
getAudiobookDetails: vi.fn(),
}));
const configServiceGet = vi.hoisted(() => vi.fn());
const jobQueueMock = vi.hoisted(() => ({
addSearchJob: vi.fn().mockResolvedValue(undefined),
addNotificationJob: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => audibleServiceMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => ({ get: configServiceGet }),
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
function futureIso(days = 30): string {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
}
function pastIso(days = 30): string {
return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
}
describe('BookDate swipe — release-date gate', () => {
beforeEach(() => {
vi.clearAllMocks();
jobQueueMock.addSearchJob.mockResolvedValue(undefined);
jobQueueMock.addNotificationJob.mockResolvedValue(undefined);
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('creates request in awaiting_release with no search when unreleased + setting ON', async () => {
authRequest.json.mockResolvedValue({ recommendationId: 'rec-future', action: 'right', markedAsKnown: false });
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
id: 'rec-future',
userId: 'user-1',
title: 'Future Book',
author: 'Future Author',
audnexusAsin: 'ASIN-FUTURE',
} as any);
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({
releaseDate: futureIso(45),
});
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({
id: 'ab-future',
title: 'Future Book',
author: 'Future Author',
audibleAsin: 'ASIN-FUTURE',
} as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'admin',
autoApproveRequests: null,
plexUsername: 'admin',
} as any);
configServiceGet.mockResolvedValueOnce(null); // default → ON
prismaMock.request.create.mockResolvedValueOnce({
id: 'req-future',
audiobook: { title: 'Future Book' },
user: { id: 'user-1', plexUsername: 'admin' },
} as any);
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(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'awaiting_release',
releaseDate: expect.any(Date),
}),
})
);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
});
it('creates pending request and runs search when released + setting ON', async () => {
authRequest.json.mockResolvedValue({ recommendationId: 'rec-past', action: 'right', markedAsKnown: false });
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
id: 'rec-past',
userId: 'user-1',
title: 'Old Book',
author: 'Old Author',
audnexusAsin: 'ASIN-PAST',
} as any);
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({
releaseDate: pastIso(365),
});
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({
id: 'ab-past',
title: 'Old Book',
author: 'Old Author',
audibleAsin: 'ASIN-PAST',
} as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'admin',
autoApproveRequests: null,
plexUsername: 'admin',
} as any);
configServiceGet.mockResolvedValueOnce('true');
prismaMock.request.create.mockResolvedValueOnce({
id: 'req-past',
audiobook: { title: 'Old Book' },
user: { id: 'user-1', plexUsername: 'admin' },
} as any);
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(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'pending',
}),
})
);
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
});
it('creates pending request and runs search when unreleased + setting OFF', async () => {
authRequest.json.mockResolvedValue({ recommendationId: 'rec-off', action: 'right', markedAsKnown: false });
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
id: 'rec-off',
userId: 'user-1',
title: 'Off Book',
author: 'Off Author',
audnexusAsin: 'ASIN-OFF',
} as any);
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({
releaseDate: futureIso(45),
});
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({
id: 'ab-off',
title: 'Off Book',
author: 'Off Author',
audibleAsin: 'ASIN-OFF',
} as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'admin',
autoApproveRequests: null,
plexUsername: 'admin',
} as any);
configServiceGet.mockResolvedValueOnce('false');
prismaMock.request.create.mockResolvedValueOnce({
id: 'req-off',
audiobook: { title: 'Off Book' },
user: { id: 'user-1', plexUsername: 'admin' },
} as any);
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(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'pending',
}),
})
);
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
});
});
@@ -40,6 +40,7 @@ const baseSettings = {
},
registration: { enabled: true, requireAdminApproval: false },
prowlarr: { url: 'http://prowlarr', apiKey: 'key' },
indexerOptions: { skipUnreleased: true },
downloadClient: {
type: 'qbittorrent',
url: 'http://qb',
@@ -275,6 +276,7 @@ describe('admin settings helpers', () => {
it('saves prowlarr settings with enabled indexers and flag configs', async () => {
fetchWithAuthMock
.mockResolvedValueOnce(makeOk())
.mockResolvedValueOnce(makeOk())
.mockResolvedValueOnce(makeOk());
@@ -289,6 +291,16 @@ describe('admin settings helpers', () => {
const body = JSON.parse((fetchWithAuthMock.mock.calls[1][1] as RequestInit).body as string);
expect(body.indexers[0].enabled).toBe(true);
expect(body.flagConfigs).toHaveLength(1);
// Indexer options PUT goes last in the prowlarr tab save flow.
expect(fetchWithAuthMock).toHaveBeenCalledWith(
'/api/admin/settings/indexer-options',
expect.objectContaining({ method: 'PUT' })
);
const optionsBody = JSON.parse(
(fetchWithAuthMock.mock.calls[2][1] as RequestInit).body as string
);
expect(optionsBody.skipUnreleased).toBe(true);
});
it('saves download and paths settings', async () => {
@@ -170,4 +170,52 @@ describe('RequestCard', () => {
expect(screen.getByText(/Completed/)).toBeInTheDocument();
});
it('renders release date when status is awaiting_release and releaseDate is provided', async () => {
const { RequestCard } = await import('@/components/requests/RequestCard');
render(
<RequestCard
request={{
...baseRequest,
status: 'awaiting_release',
releaseDate: '2026-08-15T00:00:00Z',
}}
/>
);
expect(screen.getByText('Releases Aug 15, 2026')).toBeInTheDocument();
});
it('does not render release text when status is awaiting_release but releaseDate is null', async () => {
const { RequestCard } = await import('@/components/requests/RequestCard');
render(
<RequestCard
request={{
...baseRequest,
status: 'awaiting_release',
releaseDate: null,
}}
/>
);
expect(screen.queryByText(/^Releases /)).toBeNull();
});
it('does not render release text when releaseDate is provided but status is not awaiting_release', async () => {
const { RequestCard } = await import('@/components/requests/RequestCard');
render(
<RequestCard
request={{
...baseRequest,
status: 'pending',
releaseDate: '2026-08-15T00:00:00Z',
}}
/>
);
expect(screen.queryByText(/^Releases /)).toBeNull();
});
});
@@ -20,4 +20,14 @@ describe('StatusBadge', () => {
render(<StatusBadge status="custom_status" />);
expect(screen.getByText('custom_status')).toBeInTheDocument();
});
it('renders the awaiting_release label with teal styling', () => {
render(<StatusBadge status="awaiting_release" />);
const badge = screen.getByText('Awaiting Release');
expect(badge).toBeInTheDocument();
expect(badge.className).toContain('bg-teal-100');
expect(badge.className).toContain('text-teal-800');
expect(badge.className).toContain('dark:bg-teal-900');
expect(badge.className).toContain('dark:text-teal-200');
});
});
+1
View File
@@ -7,6 +7,7 @@ import { vi } from 'vitest';
export const createJobQueueMock = () => ({
addSearchJob: vi.fn(),
addSearchEbookJob: vi.fn(),
addDownloadJob: vi.fn(),
addMonitorJob: vi.fn(),
addOrganizeJob: vi.fn(),
@@ -28,15 +28,26 @@ vi.mock('@/lib/integrations/prowlarr.service', () => ({
getProwlarrService: () => prowlarrMock,
}));
function futureDate(days = 30): Date {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
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 }])
);
// Indexer config + skip_unreleased setting both read via the same mock — return appropriate value per key.
configMock.get.mockImplementation(async (key: string) => {
if (key === 'prowlarr_indexers') {
return JSON.stringify([{ id: 1, name: 'Indexer', rssEnabled: true }]);
}
if (key === 'indexer.skip_unreleased') {
return null; // default ON
}
return null;
});
prowlarrMock.getAllRssFeeds.mockResolvedValue([
{ title: 'Great Book - Author Name' },
@@ -45,6 +56,9 @@ describe('processMonitorRssFeeds', () => {
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-1',
type: 'audiobook',
status: 'awaiting_search',
releaseDate: null,
audiobook: { id: 'a1', title: 'Great Book', author: 'Author Name', audibleAsin: 'ASIN1' },
},
]);
@@ -58,6 +72,75 @@ describe('processMonitorRssFeeds', () => {
expect.objectContaining({ title: 'Great Book', author: 'Author Name' })
);
});
it('skips RSS auto-search when matched book is unreleased and setting ON', async () => {
configMock.get.mockImplementation(async (key: string) => {
if (key === 'prowlarr_indexers') {
return JSON.stringify([{ id: 1, name: 'Indexer', rssEnabled: true }]);
}
if (key === 'indexer.skip_unreleased') {
return 'true';
}
return null;
});
prowlarrMock.getAllRssFeeds.mockResolvedValue([
{ title: 'Future Book - Author Name' },
]);
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-future',
type: 'audiobook',
status: 'awaiting_search',
releaseDate: futureDate(45),
audiobook: { id: 'a-future', title: 'Future Book', author: 'Author Name', audibleAsin: 'ASIN-F' },
},
]);
const { processMonitorRssFeeds } = await import('@/lib/processors/monitor-rss-feeds.processor');
const result = await processMonitorRssFeeds({ jobId: 'job-2' });
expect(result.success).toBe(true);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
// Request status must not be mutated by RSS processor.
expect(prismaMock.request.update).not.toHaveBeenCalled();
});
it('runs RSS search when matched book is unreleased but setting is OFF', async () => {
configMock.get.mockImplementation(async (key: string) => {
if (key === 'prowlarr_indexers') {
return JSON.stringify([{ id: 1, name: 'Indexer', rssEnabled: true }]);
}
if (key === 'indexer.skip_unreleased') {
return 'false';
}
return null;
});
prowlarrMock.getAllRssFeeds.mockResolvedValue([
{ title: 'Future Book - Author Name' },
]);
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-future-off',
type: 'audiobook',
status: 'awaiting_search',
releaseDate: futureDate(45),
audiobook: { id: 'a-future', title: 'Future Book', author: 'Author Name', audibleAsin: 'ASIN-F' },
},
]);
const { processMonitorRssFeeds } = await import('@/lib/processors/monitor-rss-feeds.processor');
const result = await processMonitorRssFeeds({ jobId: 'job-3' });
expect(result.success).toBe(true);
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith(
'req-future-off',
expect.objectContaining({ title: 'Future Book', author: 'Author Name' })
);
expect(prismaMock.request.update).not.toHaveBeenCalled();
});
});
@@ -9,6 +9,7 @@ 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,
@@ -18,15 +19,32 @@ vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
function futureDate(days = 30): Date {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
function pastDate(days = 30): Date {
return new Date(Date.now() - days * 24 * 60 * 60 * 1000);
}
describe('processRetryMissingTorrents', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: setting ON (default when absent)
configMock.get.mockResolvedValue(null);
});
it('queues search jobs for awaiting_search requests', async () => {
it('queues search jobs for awaiting_search requests with no release date', async () => {
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-1',
type: 'audiobook',
status: 'awaiting_search',
releaseDate: null,
audiobook: { id: 'a1', title: 'Book', author: 'Author', audibleAsin: 'ASIN1' },
},
]);
@@ -39,7 +57,103 @@ describe('processRetryMissingTorrents', () => {
'req-1',
expect.objectContaining({ id: 'a1', title: 'Book', author: 'Author' })
);
expect(prismaMock.request.update).not.toHaveBeenCalled();
});
it('transitions awaiting_search → awaiting_release when book is unreleased and setting ON', async () => {
configMock.get.mockResolvedValue('true');
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-2',
type: 'audiobook',
status: 'awaiting_search',
releaseDate: futureDate(30),
audiobook: { id: 'a2', title: 'Future Book', author: 'Future Author', audibleAsin: 'ASIN2' },
},
]);
const { processRetryMissingTorrents } = await import('@/lib/processors/retry-missing-torrents.processor');
const result = await processRetryMissingTorrents({ jobId: 'job-2' });
expect(result.success).toBe(true);
expect(prismaMock.request.update).toHaveBeenCalledWith({
where: { id: 'req-2' },
data: { status: 'awaiting_release' },
});
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
expect(jobQueueMock.addSearchEbookJob).not.toHaveBeenCalled();
expect(result.transitioned).toBe(1);
expect(result.skipped).toBe(1);
});
it('transitions awaiting_release → awaiting_search and runs search when release date passed', async () => {
configMock.get.mockResolvedValue('true');
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-3',
type: 'audiobook',
status: 'awaiting_release',
releaseDate: pastDate(5),
audiobook: { id: 'a3', title: 'Released Book', author: 'Some Author', audibleAsin: 'ASIN3' },
},
]);
const { processRetryMissingTorrents } = await import('@/lib/processors/retry-missing-torrents.processor');
const result = await processRetryMissingTorrents({ jobId: 'job-3' });
expect(result.success).toBe(true);
expect(prismaMock.request.update).toHaveBeenCalledWith({
where: { id: 'req-3' },
data: { status: 'awaiting_search' },
});
expect(jobQueueMock.addSearchJob).toHaveBeenCalledWith(
'req-3',
expect.objectContaining({ id: 'a3', title: 'Released Book', author: 'Some Author' })
);
expect(result.transitioned).toBe(1);
expect(result.triggered).toBe(1);
});
it('leaves awaiting_release as-is when book is still unreleased', async () => {
configMock.get.mockResolvedValue('true');
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-4',
type: 'audiobook',
status: 'awaiting_release',
releaseDate: futureDate(60),
audiobook: { id: 'a4', title: 'Still Future', author: 'Author', audibleAsin: 'ASIN4' },
},
]);
const { processRetryMissingTorrents } = await import('@/lib/processors/retry-missing-torrents.processor');
const result = await processRetryMissingTorrents({ jobId: 'job-4' });
expect(result.success).toBe(true);
expect(prismaMock.request.update).not.toHaveBeenCalled();
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
expect(result.skipped).toBe(1);
expect(result.transitioned).toBe(0);
});
it('runs search for awaiting_search with future date when setting is OFF', async () => {
configMock.get.mockResolvedValue('false');
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-5',
type: 'audiobook',
status: 'awaiting_search',
releaseDate: futureDate(10),
audiobook: { id: 'a5', title: 'Off Setting Book', author: 'Author', audibleAsin: 'ASIN5' },
},
]);
const { processRetryMissingTorrents } = await import('@/lib/processors/retry-missing-torrents.processor');
const result = await processRetryMissingTorrents({ jobId: 'job-5' });
expect(result.success).toBe(true);
expect(prismaMock.request.update).not.toHaveBeenCalled();
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
expect(result.triggered).toBe(1);
});
});
+137 -9
View File
@@ -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();
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Component: Release Date Utilities Tests
* Documentation: documentation/backend/database.md
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { isUnreleased, shouldSkipAutoSearch } from '@/lib/utils/release-date';
describe('isUnreleased', () => {
it('returns false for null', () => {
expect(isUnreleased(null)).toBe(false);
});
it('returns false for undefined', () => {
expect(isUnreleased(undefined)).toBe(false);
});
it('returns false for empty string', () => {
expect(isUnreleased('')).toBe(false);
});
it('returns false for malformed string', () => {
expect(isUnreleased('not-a-date')).toBe(false);
});
it('returns false when release date is today (UTC date-only)', () => {
const now = new Date();
const today = new Date(Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate()
));
expect(isUnreleased(today)).toBe(false);
});
it('returns false when release date is yesterday', () => {
const now = new Date();
const yesterday = new Date(Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() - 1
));
expect(isUnreleased(yesterday)).toBe(false);
});
it('returns true when release date is tomorrow', () => {
const now = new Date();
const tomorrow = new Date(Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() + 1
));
expect(isUnreleased(tomorrow)).toBe(true);
});
it('returns true for far-future ISO date string', () => {
expect(isUnreleased('2099-01-01')).toBe(true);
});
it('returns true for far-future ISO datetime string', () => {
expect(isUnreleased('2099-01-01T00:00:00Z')).toBe(true);
});
it('returns false for far-past Date object', () => {
expect(isUnreleased(new Date('1990-01-01'))).toBe(false);
});
it('returns true for far-future Date object', () => {
expect(isUnreleased(new Date('2099-01-01'))).toBe(true);
});
describe('UTC boundary cases with fake timers', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('treats same UTC day as released regardless of clock time', () => {
// Pin "now" to mid-day UTC on 2026-06-15
vi.setSystemTime(new Date('2026-06-15T12:00:00Z'));
// A release date at the very start of the same UTC day → released
expect(isUnreleased('2026-06-15T00:00:00Z')).toBe(false);
// A release date at the very end of the same UTC day → released
expect(isUnreleased('2026-06-15T23:59:59Z')).toBe(false);
});
it('treats next UTC day as unreleased', () => {
vi.setSystemTime(new Date('2026-06-15T23:59:59Z'));
expect(isUnreleased('2026-06-16T00:00:00Z')).toBe(true);
});
it('treats previous UTC day as released', () => {
vi.setSystemTime(new Date('2026-06-15T00:00:00Z'));
expect(isUnreleased('2026-06-14T23:59:59Z')).toBe(false);
});
});
});
describe('shouldSkipAutoSearch', () => {
const tomorrow = new Date();
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
const yesterday = new Date();
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
it('does not skip when setting is OFF, even if unreleased', () => {
expect(shouldSkipAutoSearch({ releaseDate: tomorrow }, false)).toEqual({
skip: false,
});
});
it('skips with reason "unreleased" when setting ON and release is in the future', () => {
expect(shouldSkipAutoSearch({ releaseDate: tomorrow }, true)).toEqual({
skip: true,
reason: 'unreleased',
});
});
it('does not skip when setting ON and release is in the past', () => {
expect(shouldSkipAutoSearch({ releaseDate: yesterday }, true)).toEqual({
skip: false,
});
});
it('does not skip when setting ON and releaseDate is null', () => {
expect(shouldSkipAutoSearch({ releaseDate: null }, true)).toEqual({
skip: false,
});
});
});