mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
6f8ac86a43
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.
212 lines
7.3 KiB
TypeScript
212 lines
7.3 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|