mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user