From 6f8ac86a43f87a440e338a7a2a542421030a1fd0 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 15 May 2026 15:35:01 -0400 Subject: [PATCH] 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. --- .gitignore | 3 + documentation/TABLEOFCONTENTS.md | 1 + documentation/backend/database.md | 4 +- documentation/backend/services/jobs.md | 1 + documentation/backend/services/scheduler.md | 4 +- documentation/frontend/components.md | 4 +- documentation/settings-pages.md | 30 +++ prisma/schema.prisma | 3 +- .../admin/components/RecentRequestsTable.tsx | 3 + .../components/RequestActionsDropdown.tsx | 4 +- src/app/admin/settings/lib/helpers.ts | 11 + src/app/admin/settings/lib/types.ts | 14 ++ .../settings/tabs/IndexersTab/IndexersTab.tsx | 42 ++++ .../admin/settings/indexer-options/route.ts | 114 ++++++++++ src/app/api/admin/settings/route.ts | 6 + src/app/api/bookdate/swipe/route.ts | 65 ++++-- .../api/requests/[id]/manual-search/route.ts | 4 +- src/app/api/requests/[id]/route.ts | 2 +- src/app/api/requests/route.ts | 2 +- src/components/audiobooks/AudiobookCard.tsx | 2 +- .../audiobooks/AudiobookDetailsModal.tsx | 2 +- src/components/requests/RequestCard.tsx | 18 ++ src/components/requests/StatusBadge.tsx | 4 + src/lib/constants/request-statuses.ts | 1 + .../processors/monitor-rss-feeds.processor.ts | 19 ++ .../retry-missing-torrents.processor.ts | 132 ++++++++--- src/lib/services/request-creator.service.ts | 41 +++- src/lib/utils/release-date.ts | 55 +++++ tests/api/bookdate-swipe.routes.test.ts | 211 ++++++++++++++++++ tests/app/admin/settings/lib/helpers.test.ts | 12 + .../components/requests/RequestCard.test.tsx | 48 ++++ .../components/requests/StatusBadge.test.tsx | 10 + tests/helpers/job-queue.ts | 1 + .../monitor-rss-feeds.processor.test.ts | 93 +++++++- .../retry-missing-torrents.processor.test.ts | 120 +++++++++- tests/services/request-creator-ignore.test.ts | 146 +++++++++++- tests/utils/release-date.test.ts | 134 +++++++++++ 37 files changed, 1289 insertions(+), 77 deletions(-) create mode 100644 src/app/api/admin/settings/indexer-options/route.ts create mode 100644 src/lib/utils/release-date.ts create mode 100644 tests/api/bookdate-swipe.routes.test.ts create mode 100644 tests/utils/release-date.test.ts diff --git a/.gitignore b/.gitignore index a834ce8..ac341f0 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ next-env.d.ts /test-data /bookdrop dockerfile.patch + +# zach-flow scratch artifacts (locked briefs, orchestrator state) +.zach-flow/ diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index c36fde7..5a5ecb9 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -59,6 +59,7 @@ - **Ebook delete behavior (files only, torrents seed)** → [integrations/ebook-sidecar.md](integrations/ebook-sidecar.md#delete-behavior) - **Ebook settings (3-section UI)** → [settings-pages.md](settings-pages.md#e-book-sidecar) - **Indexer categories (audiobook/ebook tabs)** → [settings-pages.md](settings-pages.md#indexer-categories-tabbed) +- **Auto-search behavior toggle (skip unreleased books)** → [settings-pages.md](settings-pages.md#auto-search-behavior-indexers-tab) ## Automation Pipeline - **Full pipeline overview** → [phase3/README.md](phase3/README.md) diff --git a/documentation/backend/database.md b/documentation/backend/database.md index c1811fb..87e861b 100644 --- a/documentation/backend/database.md +++ b/documentation/backend/database.md @@ -60,12 +60,14 @@ PostgreSQL database storing users, audiobooks, requests, downloads, configuratio ### Requests - `id` (UUID PK), `user_id` (FK), `audiobook_id` (FK) -- `status` ('pending'|'searching'|'downloading'|'processing'|'downloaded'|'available'|'failed'|'cancelled'|'awaiting_search'|'awaiting_import'|'warn'|'awaiting_approval'|'denied') +- `status` ('pending'|'searching'|'downloading'|'processing'|'downloaded'|'available'|'failed'|'cancelled'|'awaiting_search'|'awaiting_import'|'awaiting_release'|'warn'|'awaiting_approval'|'denied') - **Approval flow:** awaiting_approval → (approve) → pending → searching → downloading → processing → downloaded → available - **Denial flow:** awaiting_approval → (deny) → denied - **awaiting_approval** - Request pending admin approval (only if auto-approve disabled) - **denied** - Request rejected by admin (terminal state) - **pending** - Request approved and queued for processing + - **awaiting_release** - Book has a future release date; auto-search skipped until release (admin toggle controls behavior) +- `release_date` (Date, nullable) - Book release date snapshot from Audnexus at request creation; used by skip-unreleased-auto-search gate - `progress` (0-100), `priority`, `error_message` - `search_attempts`, `download_attempts`, `import_attempts`, `max_import_retries` (default 5) - `last_search_at`, `last_import_at`, `created_at`, `updated_at`, `completed_at` diff --git a/documentation/backend/services/jobs.md b/documentation/backend/services/jobs.md index 3198d29..8ccc016 100644 --- a/documentation/backend/services/jobs.md +++ b/documentation/backend/services/jobs.md @@ -48,6 +48,7 @@ Manages background job queue using Bull (Redis-backed) for async tasks: searchin **search_indexers:** - No torrents found → 'awaiting_search' status (not failed) - Allows automatic retry via scheduled job +- Upstream release-date gate: 4 enqueue sites (`request-creator.service`, `retry-missing-torrents.processor`, `monitor-rss-feeds.processor`, `bookdate/swipe/route`) check `shouldSkipAutoSearch` against `indexer.skip_unreleased`; gated requests are created/kept in `awaiting_release` and `addSearchJob` is not called. Manual search bypasses the gate. **organize_files:** - No audiobook files found → 'awaiting_import' status diff --git a/documentation/backend/services/scheduler.md b/documentation/backend/services/scheduler.md index da38edb..20a41bc 100644 --- a/documentation/backend/services/scheduler.md +++ b/documentation/backend/services/scheduler.md @@ -18,10 +18,10 @@ Manages recurring/scheduled jobs providing automated tasks (Plex scans, Audible 1. **plex_library_scan** - Default: every 6 hours, full library scan, disabled by default (enable after setup) 2. **plex_recently_added_check** - Default: every 5 minutes, lightweight polling of top 10 recently added items, enabled by default 3. **audible_refresh** - Default: daily midnight, fetches 200 popular + 200 new releases, stores with rankings, disabled by default -4. **retry_missing_torrents** - Default: daily midnight, re-searches 'awaiting_search' status (limit 50), handles both audiobook and ebook requests, enabled by default +4. **retry_missing_torrents** - Default: daily midnight, processes union of `awaiting_search` ∪ `awaiting_release` (limit 50), handles both audiobook and ebook requests. Bidirectional transitions: `awaiting_search` → `awaiting_release` when release date is future + `indexer.skip_unreleased` ON; `awaiting_release` → `awaiting_search` + run search when release date has passed or setting OFF. Sole owner of these transitions. Enabled by default. 5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default 6. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met, respects `seeding_time_minutes` config (0 = never), enabled by default -7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against 'awaiting_search' requests (audiobook and ebook, limit 100), triggers appropriate search jobs for matches, enabled by default +7. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against `awaiting_search` requests (audiobook and ebook, limit 100). Query is unchanged — release-date gate is applied AFTER a match is found: if matched book is unreleased + `indexer.skip_unreleased` ON, the match is skipped and request status is NOT mutated (retry job owns transitions). Enabled by default. ## Architecture: Bull + Cron diff --git a/documentation/frontend/components.md b/documentation/frontend/components.md index e1d6e3d..efc1a9a 100644 --- a/documentation/frontend/components.md +++ b/documentation/frontend/components.md @@ -33,8 +33,8 @@ src/components/ - **AudiobookDetailsModal** ✅ - Full-screen modal with comprehensive metadata (description, genres, rating, release date, narrator, language, format, publisher, request functionality). Shows requesting user's name when applicable **Requests** -- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search) -- **StatusBadge** - Color-coded status (pending=yellow, searching=blue, downloading=purple, downloaded=green, processing=orange, available=green, completed=green, failed=red, warn=orange, cancelled=gray). Shows "Initializing..." when downloading with 0% progress (fetching torrent info), "Downloading" when progress > 0% +- **RequestCard** ✅ - Cover, title, author, status badge, progress bar, timestamps, action buttons (cancel, manual search, interactive search). When status=`awaiting_release` and `releaseDate` is set, shows "Releases <Mon DD, YYYY>" next to the status badge (UTC-formatted) +- **StatusBadge** - Color-coded status (pending=yellow, awaiting_search=yellow, searching=blue, downloading=purple, downloaded=green, processing=orange, awaiting_import=orange, available=green, completed=green, failed=red, warn=orange, cancelled=gray, awaiting_approval=yellow, awaiting_release=teal "Awaiting Release", denied=red). Shows "Initializing..." when downloading with 0% progress (fetching torrent info), "Downloading" when progress > 0% - **ProgressBar** - Animated fill with percentage - **InteractiveTorrentSearchModal** ✅ - Responsive table of ranked torrent results, uses ConfirmModal for downloads, hides columns on smaller screens (size on mobile, seeds on tablet, indexer on desktop) - Active indicator: "Setting up..." with spinner when progress = 0%, "Active" with pulsing dot when progress > 0% diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md index db299b2..57ba0b5 100644 --- a/documentation/settings-pages.md +++ b/documentation/settings-pages.md @@ -130,6 +130,25 @@ src/app/admin/settings/ } ``` +## Auto-Search Behavior (Indexers tab) + +**Purpose:** Control how ReadMeABook performs automatic indexer searches. Lives on the Indexers tab between the Prowlarr connection block and the IndexerManagement list. + +**Toggle:** Skip unreleased books in automatic searches +- When ON: auto-search skips books whose release date is in the future. Those requests automatically start searching once the book is released. Manual searches are unaffected. +- When OFF: auto-search proceeds regardless of release date. + +**Configuration Key:** +| Key | Default | Category | Description | +|-----|---------|----------|-------------| +| `indexer.skip_unreleased` | `true` (ON) | `indexer` | Skip auto-searches for books with future release dates | + +**Read contract (consumed by background workers):** +- `value !== 'false'` → ON (skip enabled). Missing key OR any non-`'false'` value → ON. +- Only the exact string `'false'` disables the toggle. Workers MUST match this. + +**API:** Persisted via `PUT /api/admin/settings/indexer-options`. Saved alongside Prowlarr connection + indexer config when the Indexers tab Save button is clicked. + ## Audible Region **Purpose:** Configure which Audible region to use for metadata and search to ensure accurate ASIN matching with your metadata engine. @@ -279,6 +298,17 @@ src/app/admin/settings/ - No test required if URL/API key unchanged - Saves only enabled indexers to database +**GET /api/admin/settings/indexer-options** +- Returns `{ skipUnreleased: boolean }` +- Default ON: missing or non-`'false'` value resolves to `true` +- Admin auth required + +**PUT /api/admin/settings/indexer-options** +- Updates indexer-wide auto-search options +- Body: `{ skipUnreleased: boolean }` (strict boolean validation) +- Persists `indexer.skip_unreleased` (category: `indexer`) +- No connection test required + **PUT /api/admin/settings/download-client** - Updates download client config - Requires prior successful test if credentials changed diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1e4c2cf..de5bebe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -223,7 +223,7 @@ model Request { userId String @map("user_id") audiobookId String @map("audiobook_id") status String @default("pending") - // Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, warn + // Status values: pending, awaiting_approval, denied, searching, downloading, processing, downloaded, available, failed, cancelled, awaiting_search, awaiting_import, awaiting_release, warn // Flow (audiobook): pending → searching → downloading → processing → downloaded → available (when matched in Plex) // Flow (ebook): pending → searching → downloading → processing → downloaded (terminal - no available state) progress Int @default(0) // 0-100 @@ -240,6 +240,7 @@ model Request { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") completedAt DateTime? @map("completed_at") + releaseDate DateTime? @map("release_date") @db.Date // Book release date (copied from Audnexus on creation). Used by skip-unreleased-auto-search gate. // Request type: 'audiobook' (default) or 'ebook' // Ebook requests are created automatically when an audiobook is organized (if ebook downloads enabled) diff --git a/src/app/admin/components/RecentRequestsTable.tsx b/src/app/admin/components/RecentRequestsTable.tsx index 65e7c95..611d10c 100644 --- a/src/app/admin/components/RecentRequestsTable.tsx +++ b/src/app/admin/components/RecentRequestsTable.tsx @@ -55,6 +55,7 @@ const STATUS_OPTIONS = [ { value: 'pending', label: 'Pending' }, { value: 'awaiting_approval', label: 'Awaiting Approval' }, { value: 'awaiting_search', label: 'Awaiting Search' }, + { value: 'awaiting_release', label: 'Awaiting Release' }, { value: 'searching', label: 'Searching' }, { value: 'downloading', label: 'Downloading' }, { value: 'processing', label: 'Processing' }, @@ -78,6 +79,7 @@ function getStatusBadge(status: string) { pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', awaiting_approval: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', awaiting_search: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + awaiting_release: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200', searching: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', downloading: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', downloaded: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', @@ -95,6 +97,7 @@ function getStatusBadge(status: string) { const labels: Record = { awaiting_search: 'Awaiting Search', + awaiting_release: 'Awaiting Release', awaiting_import: 'Awaiting Import', awaiting_approval: 'Awaiting Approval', }; diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index 6f9563f..e56fe9c 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -69,8 +69,8 @@ export function RequestActionsDropdown({ const canViewDetails = !isEbook && !!request.asin && !!onViewDetails; // Determine available actions based on status - const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status); - const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status); + const canSearch = ['pending', 'failed', 'awaiting_search', 'awaiting_release'].includes(request.status); + const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'awaiting_release', 'searching'].includes(request.status); const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload; const canCancel = (CANCELLABLE_STATUSES as readonly string[]).includes(request.status); const canDelete = true; // Admins can always delete diff --git a/src/app/admin/settings/lib/helpers.ts b/src/app/admin/settings/lib/helpers.ts index 38271c5..e210b31 100644 --- a/src/app/admin/settings/lib/helpers.ts +++ b/src/app/admin/settings/lib/helpers.ts @@ -113,6 +113,17 @@ export const saveTabSettings = async ( }).then(res => { if (!res.ok) throw new Error('Failed to save indexer configuration'); }); + + // Save indexer-wide options (auto-search behavior, etc.) + await fetchWithAuth('/api/admin/settings/indexer-options', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + skipUnreleased: settings.indexerOptions.skipUnreleased, + }), + }).then(res => { + if (!res.ok) throw new Error('Failed to save indexer options'); + }); break; case 'download': diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index 264c374..f531b51 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -16,6 +16,7 @@ export interface Settings { oidc: OIDCSettings; registration: RegistrationSettings; prowlarr: ProwlarrSettings; + indexerOptions: IndexerOptionsSettings; downloadClient: DownloadClientSettings; paths: PathsSettings; ebook: EbookSettings; @@ -76,6 +77,19 @@ export interface ProwlarrSettings { apiKey: string; } +/** + * Indexer-wide behavioral options (not tied to a specific indexer connection). + * Persisted via `/api/admin/settings/indexer-options`. + */ +export interface IndexerOptionsSettings { + /** + * When true, automatic indexer searches skip books whose release date is + * in the future. Default ON. Manual searches are unaffected. + * Backing config key: `indexer.skip_unreleased`. + */ + skipUnreleased: boolean; +} + /** * Download client (qBittorrent) configuration */ diff --git a/src/app/admin/settings/tabs/IndexersTab/IndexersTab.tsx b/src/app/admin/settings/tabs/IndexersTab/IndexersTab.tsx index 32a2dbf..c56bdce 100644 --- a/src/app/admin/settings/tabs/IndexersTab/IndexersTab.tsx +++ b/src/app/admin/settings/tabs/IndexersTab/IndexersTab.tsx @@ -136,6 +136,48 @@ export function IndexersTab({ )} +
+
+

+ Auto-Search Behavior +

+

+ Control how ReadMeABook performs automatic background searches across your indexers. +

+
+ +
+
+ + onChange({ + ...settings, + indexerOptions: { + ...settings.indexerOptions, + skipUnreleased: e.target.checked, + }, + }) + } + className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500" + /> +
+ +

+ When ON, ReadMeABook will not search indexers for books whose release date is in the future. These requests will automatically begin searching once the book is released. Manual searches are not affected. +

+
+
+
+
+
{ + return requireAdmin(req, async () => { + try { + const configService = getConfigService(); + const value = await configService.get(CONFIG_KEY); + + // Default ON: missing or any value other than 'false' is treated as enabled. + const skipUnreleased = value !== 'false'; + + return NextResponse.json({ skipUnreleased }); + } catch (error) { + logger.error('Failed to fetch indexer options', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Failed to fetch indexer options' }, + { status: 500 } + ); + } + }); + }); +} + +/** + * PUT /api/admin/settings/indexer-options + * Persists indexer-wide options. Body: { skipUnreleased: boolean } + */ +export async function PUT(request: NextRequest) { + return requireAuth(request, async (req: AuthenticatedRequest) => { + return requireAdmin(req, async () => { + try { + const body = await request.json(); + const { skipUnreleased } = body ?? {}; + + if (typeof skipUnreleased !== 'boolean') { + return NextResponse.json( + { error: 'skipUnreleased must be a boolean' }, + { status: 400 } + ); + } + + const configService = getConfigService(); + await configService.setMany([ + { + key: CONFIG_KEY, + value: String(skipUnreleased), + category: 'indexer', + description: + 'Skip auto-searches for books with future release dates', + }, + ]); + + // Explicitly clear cache for the key after write. `setMany` already + // does this, but we make it visible here to guarantee fresh reads + // by any sibling service that has cached the value. + configService.clearCache(CONFIG_KEY); + + logger.info('Indexer options updated', { skipUnreleased }); + + return NextResponse.json({ + success: true, + message: 'Indexer options updated successfully', + }); + } catch (error) { + logger.error('Failed to update indexer options', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { + success: false, + error: + error instanceof Error + ? error.message + : 'Failed to update indexer options', + }, + { status: 500 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 3298e1e..59ccf3f 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -81,6 +81,12 @@ export async function GET(request: NextRequest) { url: configMap.get('prowlarr_url') || '', apiKey: maskValue('api_key', configMap.get('prowlarr_api_key')), }, + indexerOptions: { + // Default ON: missing or any value other than 'false' is treated as enabled. + // Must stay in lock-step with /api/admin/settings/indexer-options read contract + // and any background worker that reads `indexer.skip_unreleased` directly. + skipUnreleased: configMap.get('indexer.skip_unreleased') !== 'false', + }, // downloadClient is populated from multi-client format for backward compatibility // The DownloadTab component now uses DownloadClientManagement which reads from /api/admin/settings/download-clients downloadClient: (() => { diff --git a/src/app/api/bookdate/swipe/route.ts b/src/app/api/bookdate/swipe/route.ts index 7ddccc0..27c790e 100644 --- a/src/app/api/bookdate/swipe/route.ts +++ b/src/app/api/bookdate/swipe/route.ts @@ -7,7 +7,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { getAudibleService } from '@/lib/integrations/audible.service'; +import { getConfigService } from '@/lib/services/config.service'; import { RMABLogger } from '@/lib/utils/logger'; +import { shouldSkipAutoSearch } from '@/lib/utils/release-date'; const logger = RMABLogger.create('API.BookDateSwipe'); @@ -67,16 +69,21 @@ async function handler(req: AuthenticatedRequest) { let year: number | undefined; let series: string | undefined; let seriesPart: string | undefined; + let releaseDate: Date | null = null; try { const audibleService = getAudibleService(); const audnexusData = await audibleService.getAudiobookDetails(recommendation.audnexusAsin); if (audnexusData?.releaseDate) { try { - const releaseYear = new Date(audnexusData.releaseDate).getFullYear(); - if (!isNaN(releaseYear)) { - year = releaseYear; - logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`); + const parsed = new Date(audnexusData.releaseDate); + if (!isNaN(parsed.getTime())) { + releaseDate = parsed; + const releaseYear = parsed.getFullYear(); + if (!isNaN(releaseYear)) { + year = releaseYear; + logger.debug(`Extracted year ${year} from Audnexus releaseDate: ${audnexusData.releaseDate}`); + } } } catch (error) { logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -181,8 +188,28 @@ async function handler(req: AuthenticatedRequest) { } } + // Evaluate release-date gate (only when not pending approval) + let releaseGateSkip = false; + if (!needsApproval) { + try { + const configService = getConfigService(); + const skipUnreleasedSetting = (await configService.get('indexer.skip_unreleased')) !== 'false'; + const gate = shouldSkipAutoSearch({ releaseDate }, skipUnreleasedSetting); + releaseGateSkip = gate.skip; + } catch (error) { + logger.warn(`Failed to evaluate release-date gate: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + // Determine initial status - const initialStatus = needsApproval ? 'awaiting_approval' : 'pending'; + let initialStatus: string; + if (needsApproval) { + initialStatus = 'awaiting_approval'; + } else if (releaseGateSkip) { + initialStatus = 'awaiting_release'; + } else { + initialStatus = 'pending'; + } const newRequest = await prisma.request.create({ data: { @@ -191,11 +218,21 @@ async function handler(req: AuthenticatedRequest) { status: initialStatus, type: 'audiobook', // Explicit type for user-created requests priority: 0, + releaseDate, }, }); logger.info(`Created request for "${recommendation.title}" with status: ${initialStatus}`); + if (releaseGateSkip) { + logger.info(`Skipped auto-search for unreleased book`, { + gateSource: 'BookDateSwipe', + requestId: newRequest.id, + audiobookTitle: audiobook.title, + releaseDate: releaseDate?.toISOString() ?? null, + }); + } + // Import job queue service const { getJobQueueService } = await import('@/lib/services/job-queue.service'); const jobQueue = getJobQueueService(); @@ -224,15 +261,17 @@ async function handler(req: AuthenticatedRequest) { logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) }); }); - // Trigger search job only if auto-approved - await jobQueue.addSearchJob(newRequest.id, { - id: audiobook.id, - title: audiobook.title, - author: audiobook.author, - asin: audiobook.audibleAsin || undefined, - }); + // Trigger search job only if auto-approved AND not gated by release date + if (!releaseGateSkip) { + await jobQueue.addSearchJob(newRequest.id, { + id: audiobook.id, + title: audiobook.title, + author: audiobook.author, + asin: audiobook.audibleAsin || undefined, + }); - logger.info(`Triggered search job for request ${newRequest.id}`); + logger.info(`Triggered search job for request ${newRequest.id}`); + } } } diff --git a/src/app/api/requests/[id]/manual-search/route.ts b/src/app/api/requests/[id]/manual-search/route.ts index aa52def..5205818 100644 --- a/src/app/api/requests/[id]/manual-search/route.ts +++ b/src/app/api/requests/[id]/manual-search/route.ts @@ -52,8 +52,8 @@ export async function POST( ); } - // Only allow manual search for pending, failed, awaiting_search statuses - const searchableStatuses = ['pending', 'failed', 'awaiting_search']; + // Only allow manual search for pending, failed, awaiting_search, awaiting_release statuses + const searchableStatuses = ['pending', 'failed', 'awaiting_search', 'awaiting_release']; if (!searchableStatuses.includes(requestRecord.status)) { return NextResponse.json( { diff --git a/src/app/api/requests/[id]/route.ts b/src/app/api/requests/[id]/route.ts index e407acd..db1e87b 100644 --- a/src/app/api/requests/[id]/route.ts +++ b/src/app/api/requests/[id]/route.ts @@ -182,7 +182,7 @@ export async function PATCH( } else if (action === 'retry') { // Retry failed request - allow users to retry their own warn/failed requests // Only allow retry for failed, warn, or awaiting_* statuses - const retryableStatuses = ['failed', 'warn', 'awaiting_search', 'awaiting_import']; + const retryableStatuses = ['failed', 'warn', 'awaiting_search', 'awaiting_import', 'awaiting_release']; if (!retryableStatuses.includes(requestRecord.status)) { return NextResponse.json( diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index 4b44fd9..8af4bf0 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -101,7 +101,7 @@ export async function POST(request: NextRequest) { // Status groups for server-side filtering and count aggregation const STATUS_GROUPS: Record = { active: ['pending', 'searching', 'downloading', 'processing'], - waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval'], + waiting: ['awaiting_search', 'awaiting_import', 'awaiting_approval', 'awaiting_release'], completed: ['available', 'downloaded'], failed: ['failed'], cancelled: ['cancelled', 'denied'], diff --git a/src/components/audiobooks/AudiobookCard.tsx b/src/components/audiobooks/AudiobookCard.tsx index 26a2f2b..35b9cae 100644 --- a/src/components/audiobooks/AudiobookCard.tsx +++ b/src/components/audiobooks/AudiobookCard.tsx @@ -34,7 +34,7 @@ const getStatusConfig = (audiobook: Audiobook) => { return { type: 'processing', label: 'Processing', color: 'amber' }; } - const pendingStatuses = ['pending', 'awaiting_search', 'searching', 'awaiting_approval']; + const pendingStatuses = ['pending', 'awaiting_search', 'awaiting_release', 'searching', 'awaiting_approval']; if (audiobook.requestStatus && pendingStatuses.includes(audiobook.requestStatus)) { return { type: 'pending', label: 'Requested', color: 'blue' }; } diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx index 7909146..2d790dc 100644 --- a/src/components/audiobooks/AudiobookDetailsModal.tsx +++ b/src/components/audiobooks/AudiobookDetailsModal.tsx @@ -53,7 +53,7 @@ const getStatusInfo = (isAvailable: boolean, requestStatus: string | null, reque return { type: 'processing', label: 'Processing', canRequest: false }; } - const pendingStatuses = ['pending', 'awaiting_search', 'searching', 'awaiting_approval']; + const pendingStatuses = ['pending', 'awaiting_search', 'awaiting_release', 'searching', 'awaiting_approval']; if (requestStatus && pendingStatuses.includes(requestStatus)) { const label = requestStatus === 'awaiting_approval' ? requestedByUsername ? `Pending Approval (${requestedByUsername})` : 'Pending Approval' diff --git a/src/components/requests/RequestCard.tsx b/src/components/requests/RequestCard.tsx index 6a636fc..0295881 100644 --- a/src/components/requests/RequestCard.tsx +++ b/src/components/requests/RequestCard.tsx @@ -27,6 +27,7 @@ interface RequestCardProps { updatedAt: string; completedAt?: string; downloadAvailable?: boolean; + releaseDate?: string | Date | null; audiobook: { id: string; audibleAsin?: string; @@ -58,6 +59,18 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { const isActive = ['searching', 'downloading', 'processing'].includes(request.status); const isFailed = request.status === 'failed'; + const releaseDateLabel = React.useMemo(() => { + if (request.status !== 'awaiting_release' || !request.releaseDate) return null; + const parsed = new Date(request.releaseDate); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }); + }, [request.status, request.releaseDate]); + const handleConfirmCancel = async () => { try { await cancelRequest(request.id); @@ -150,6 +163,11 @@ export function RequestCard({ request, showActions = true }: RequestCardProps) { {/* Status Badge and Type Badge */}
+ {releaseDateLabel && ( + + Releases {releaseDateLabel} + + )} {isEbook && ( = 2) { logger.info(`Match found! "${audiobook.title}" by ${audiobook.author} matches torrent: ${torrent.title}`); + // Release-date gate: skip RSS-driven auto-search for unreleased books. + // Does NOT mutate request.status — retry job is the sole owner of + // awaiting_search ↔ awaiting_release transitions. + const gate = shouldSkipAutoSearch({ releaseDate: request.releaseDate }, skipUnreleasedSetting); + if (gate.skip) { + logger.info(`Skipped RSS auto-search for unreleased book`, { + gateSource: 'MonitorRssFeeds', + requestId: request.id, + audiobookTitle: audiobook.title, + releaseDate: request.releaseDate?.toISOString() ?? null, + }); + // Match exists but is gated — preserve "only trigger once per request" semantics. + break; + } + // Trigger appropriate search job based on request type try { if (request.type === 'ebook') { diff --git a/src/lib/processors/retry-missing-torrents.processor.ts b/src/lib/processors/retry-missing-torrents.processor.ts index 72cd5fd..3d5bcf2 100644 --- a/src/lib/processors/retry-missing-torrents.processor.ts +++ b/src/lib/processors/retry-missing-torrents.processor.ts @@ -2,12 +2,17 @@ * Component: Retry Missing Torrents Processor * Documentation: documentation/backend/services/scheduler.md * - * Retries search for requests that are awaiting torrent search + * Retries search for requests that are awaiting torrent search. + * Also drives bidirectional transitions between `awaiting_search` and + * `awaiting_release` based on the per-book release date and the + * `indexer.skip_unreleased` setting. */ import { prisma } from '../db'; import { RMABLogger } from '../utils/logger'; import { getJobQueueService } from '../services/job-queue.service'; +import { getConfigService } from '../services/config.service'; +import { shouldSkipAutoSearch } from '../utils/release-date'; export interface RetryMissingTorrentsPayload { jobId?: string; @@ -15,77 +20,146 @@ export interface RetryMissingTorrentsPayload { } export async function processRetryMissingTorrents(payload: RetryMissingTorrentsPayload): Promise { - const { jobId, scheduledJobId } = payload; + const { jobId } = payload; const logger = RMABLogger.forJob(jobId, 'RetryMissingTorrents'); - logger.info('Starting retry job for requests awaiting search...'); + logger.info('Starting retry job for requests awaiting search/release...'); try { - // Find all active requests (audiobook or ebook) in awaiting_search status + // Read skip-unreleased setting once at start (default ON when absent) + const configService = getConfigService(); + const skipUnreleasedSetting = (await configService.get('indexer.skip_unreleased')) !== 'false'; + + // Find all active requests in awaiting_search OR awaiting_release status const requests = await prisma.request.findMany({ where: { - status: 'awaiting_search', + status: { in: ['awaiting_search', 'awaiting_release'] }, deletedAt: null, }, include: { audiobook: true, }, - take: 50, // Limit to 50 requests per run + take: 50, }); - logger.info(`Found ${requests.length} requests awaiting search`); + logger.info(`Found ${requests.length} requests awaiting search/release`); if (requests.length === 0) { return { success: true, - message: 'No requests awaiting search', + message: 'No requests awaiting search/release', triggered: 0, + transitioned: 0, + skipped: 0, }; } - // Trigger appropriate search job for each request based on type - // Throttle: 100ms delay between jobs to avoid connection pool burst const jobQueue = getJobQueueService(); let triggered = 0; + let transitioned = 0; + let skipped = 0; for (const request of requests) { try { - if (request.type === 'ebook') { - // Ebook requests use ebook search (Anna's Archive, etc.) - await jobQueue.addSearchEbookJob(request.id, { - id: request.audiobook.id, - title: request.audiobook.title, - author: request.audiobook.author, - asin: request.audiobook.audibleAsin || undefined, + const gate = shouldSkipAutoSearch({ releaseDate: request.releaseDate }, skipUnreleasedSetting); + + if (request.status === 'awaiting_search' && gate.skip) { + // Future release, setting ON → demote to awaiting_release + await prisma.request.update({ + where: { id: request.id }, + data: { status: 'awaiting_release' }, }); + skipped++; + transitioned++; + logger.info(`Transitioned request to awaiting_release (unreleased)`, { + gateSource: 'RetryMissingTorrents', + requestId: request.id, + audiobookTitle: request.audiobook.title, + releaseDate: request.releaseDate?.toISOString() ?? null, + from: 'awaiting_search', + to: 'awaiting_release', + }); + } else if (request.status === 'awaiting_release' && !gate.skip) { + // Released (or setting OFF) → promote to awaiting_search and run search. + // Order: update status → queue job → log (race safety). + await prisma.request.update({ + where: { id: request.id }, + data: { status: 'awaiting_search' }, + }); + + if (request.type === 'ebook') { + await jobQueue.addSearchEbookJob(request.id, { + id: request.audiobook.id, + title: request.audiobook.title, + author: request.audiobook.author, + asin: request.audiobook.audibleAsin || undefined, + }); + } else { + await jobQueue.addSearchJob(request.id, { + id: request.audiobook.id, + title: request.audiobook.title, + author: request.audiobook.author, + asin: request.audiobook.audibleAsin || undefined, + }); + } triggered++; - logger.info(`Triggered ebook search for request ${request.id}: ${request.audiobook.title}`); + transitioned++; + logger.info(`Transitioned request to awaiting_search and queued search`, { + requestId: request.id, + audiobookTitle: request.audiobook.title, + releaseDate: request.releaseDate?.toISOString() ?? null, + from: 'awaiting_release', + to: 'awaiting_search', + triggeredBy: 'RetryMissingTorrents', + }); + } else if (request.status === 'awaiting_release' && gate.skip) { + // Still unreleased — leave as-is. + skipped++; + logger.info(`Skipped awaiting_release request (still unreleased)`, { + gateSource: 'RetryMissingTorrents', + requestId: request.id, + audiobookTitle: request.audiobook.title, + releaseDate: request.releaseDate?.toISOString() ?? null, + }); } else { - // Audiobook requests use indexer search (Prowlarr) - await jobQueue.addSearchJob(request.id, { - id: request.audiobook.id, - title: request.audiobook.title, - author: request.audiobook.author, - asin: request.audiobook.audibleAsin || undefined, - }); - triggered++; - logger.info(`Triggered audiobook search for request ${request.id}: ${request.audiobook.title}`); + // awaiting_search + !gate.skip → existing search path + if (request.type === 'ebook') { + await jobQueue.addSearchEbookJob(request.id, { + id: request.audiobook.id, + title: request.audiobook.title, + author: request.audiobook.author, + asin: request.audiobook.audibleAsin || undefined, + }); + triggered++; + logger.info(`Triggered ebook search for request ${request.id}: ${request.audiobook.title}`); + } else { + await jobQueue.addSearchJob(request.id, { + id: request.audiobook.id, + title: request.audiobook.title, + author: request.audiobook.author, + asin: request.audiobook.audibleAsin || undefined, + }); + triggered++; + logger.info(`Triggered audiobook search for request ${request.id}: ${request.audiobook.title}`); + } } } catch (error) { - logger.error(`Failed to trigger search for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); + logger.error(`Failed to process request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); } // Spread DB operations over time to avoid connection pool exhaustion await new Promise(resolve => setTimeout(resolve, 100)); } - logger.info(`Triggered ${triggered}/${requests.length} search jobs`); + logger.info(`Retry pass complete: triggered=${triggered}, transitioned=${transitioned}, skipped=${skipped} of ${requests.length}`); return { success: true, message: 'Retry missing torrents completed', totalRequests: requests.length, triggered, + transitioned, + skipped, }; } catch (error) { logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); diff --git a/src/lib/services/request-creator.service.ts b/src/lib/services/request-creator.service.ts index 7444a2d..d3c1423 100644 --- a/src/lib/services/request-creator.service.ts +++ b/src/lib/services/request-creator.service.ts @@ -9,9 +9,11 @@ import { prisma } from '@/lib/db'; import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { getConfigService } from '@/lib/services/config.service'; import { findPlexMatch } from '@/lib/utils/audiobook-matcher'; import { getAudibleService } from '@/lib/integrations/audible.service'; import { RMABLogger } from '@/lib/utils/logger'; +import { shouldSkipAutoSearch } from '@/lib/utils/release-date'; import { seedAsin, getSiblingAsins } from '@/lib/services/works.service'; const logger = RMABLogger.create('RequestCreator'); @@ -95,20 +97,25 @@ export async function createRequestForUser( } } - // Fetch full details from Audnexus for year/series + // Fetch full details from Audnexus for year/series/releaseDate let year: number | undefined; let series: string | undefined; let seriesPart: string | undefined; let seriesAsin: string | undefined; + let releaseDate: Date | null = null; try { const audibleService = getAudibleService(); const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin); if (audnexusData?.releaseDate) { try { - const releaseYear = new Date(audnexusData.releaseDate).getFullYear(); - if (!isNaN(releaseYear)) { - year = releaseYear; + const parsed = new Date(audnexusData.releaseDate); + if (!isNaN(parsed.getTime())) { + releaseDate = parsed; + const releaseYear = parsed.getFullYear(); + if (!isNaN(releaseYear)) { + year = releaseYear; + } } } catch { // Ignore parse errors @@ -242,12 +249,28 @@ export async function createRequestForUser( } } + // Evaluate release-date gate (skip-unreleased-auto-search) + let releaseGateSkip = false; + if (!needsApproval && !skipAutoSearch) { + try { + const configService = getConfigService(); + const skipUnreleasedSetting = (await configService.get('indexer.skip_unreleased')) !== 'false'; + const gate = shouldSkipAutoSearch({ releaseDate }, skipUnreleasedSetting); + releaseGateSkip = gate.skip; + } catch (error) { + logger.warn(`Failed to evaluate release-date gate: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + let initialStatus: string; if (needsApproval) { initialStatus = 'awaiting_approval'; shouldTriggerSearch = false; } else if (skipAutoSearch) { initialStatus = 'awaiting_search'; + } else if (releaseGateSkip) { + initialStatus = 'awaiting_release'; + shouldTriggerSearch = false; } else { initialStatus = 'pending'; } @@ -260,6 +283,7 @@ export async function createRequestForUser( status: initialStatus, type: 'audiobook', progress: 0, + releaseDate, }, include: { audiobook: true, @@ -267,6 +291,15 @@ export async function createRequestForUser( }, }); + if (releaseGateSkip) { + logger.info(`Skipped auto-search for unreleased book`, { + gateSource: 'InitialAutoSearch', + requestId: newRequest.id, + audiobookTitle: audiobookRecord.title, + releaseDate: releaseDate?.toISOString() ?? null, + }); + } + const jobQueue = getJobQueueService(); // Send notification diff --git a/src/lib/utils/release-date.ts b/src/lib/utils/release-date.ts new file mode 100644 index 0000000..d268f00 --- /dev/null +++ b/src/lib/utils/release-date.ts @@ -0,0 +1,55 @@ +/** + * Component: Release Date Utilities + * Documentation: documentation/backend/database.md + * + * Pure helpers for reasoning about a book's release date relative to "today". + * Date-only comparison in UTC — no local-timezone arithmetic and no string slicing. + */ + +/** + * Returns true when the given release date is strictly after today (UTC date-only). + * Null, undefined, empty, or malformed input returns false (safe fallback). + */ +export function isUnreleased( + releaseDate: Date | string | null | undefined +): boolean { + if (releaseDate === null || releaseDate === undefined || releaseDate === '') { + return false; + } + + try { + const date = releaseDate instanceof Date ? releaseDate : new Date(releaseDate); + if (isNaN(date.getTime())) { + return false; + } + + const now = new Date(); + const releaseY = date.getUTCFullYear(); + const releaseM = date.getUTCMonth(); + const releaseD = date.getUTCDate(); + const nowY = now.getUTCFullYear(); + const nowM = now.getUTCMonth(); + const nowD = now.getUTCDate(); + + if (releaseY !== nowY) return releaseY > nowY; + if (releaseM !== nowM) return releaseM > nowM; + return releaseD > nowD; + } catch { + return false; + } +} + +/** + * Decides whether auto-search should be skipped because the book is unreleased. + * Short-circuits when the admin toggle is off. + */ +export function shouldSkipAutoSearch( + request: { releaseDate?: Date | string | null }, + settingOn: boolean +): { skip: boolean; reason?: 'unreleased' } { + if (!settingOn) return { skip: false }; + if (isUnreleased(request.releaseDate)) { + return { skip: true, reason: 'unreleased' }; + } + return { skip: false }; +} diff --git a/tests/api/bookdate-swipe.routes.test.ts b/tests/api/bookdate-swipe.routes.test.ts new file mode 100644 index 0000000..e9faf91 --- /dev/null +++ b/tests/api/bookdate-swipe.routes.test.ts @@ -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(); + }); +}); diff --git a/tests/app/admin/settings/lib/helpers.test.ts b/tests/app/admin/settings/lib/helpers.test.ts index f39d31e..b9e53a5 100644 --- a/tests/app/admin/settings/lib/helpers.test.ts +++ b/tests/app/admin/settings/lib/helpers.test.ts @@ -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 () => { diff --git a/tests/components/requests/RequestCard.test.tsx b/tests/components/requests/RequestCard.test.tsx index 468b31b..9002f27 100644 --- a/tests/components/requests/RequestCard.test.tsx +++ b/tests/components/requests/RequestCard.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.queryByText(/^Releases /)).toBeNull(); + }); }); diff --git a/tests/components/requests/StatusBadge.test.tsx b/tests/components/requests/StatusBadge.test.tsx index 5879552..2d92bfd 100644 --- a/tests/components/requests/StatusBadge.test.tsx +++ b/tests/components/requests/StatusBadge.test.tsx @@ -20,4 +20,14 @@ describe('StatusBadge', () => { render(); expect(screen.getByText('custom_status')).toBeInTheDocument(); }); + + it('renders the awaiting_release label with teal styling', () => { + render(); + 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'); + }); }); diff --git a/tests/helpers/job-queue.ts b/tests/helpers/job-queue.ts index 6f7f256..853a354 100644 --- a/tests/helpers/job-queue.ts +++ b/tests/helpers/job-queue.ts @@ -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(), diff --git a/tests/processors/monitor-rss-feeds.processor.test.ts b/tests/processors/monitor-rss-feeds.processor.test.ts index bf66696..c36f8fa 100644 --- a/tests/processors/monitor-rss-feeds.processor.test.ts +++ b/tests/processors/monitor-rss-feeds.processor.test.ts @@ -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(); + }); }); - - diff --git a/tests/processors/retry-missing-torrents.processor.test.ts b/tests/processors/retry-missing-torrents.processor.test.ts index e11a1c1..b1b78c3 100644 --- a/tests/processors/retry-missing-torrents.processor.test.ts +++ b/tests/processors/retry-missing-torrents.processor.test.ts @@ -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); }); }); - - diff --git a/tests/services/request-creator-ignore.test.ts b/tests/services/request-creator-ignore.test.ts index 5b8b795..096ad5f 100644 --- a/tests/services/request-creator-ignore.test.ts +++ b/tests/services/request-creator-ignore.test.ts @@ -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(); + }); +}); diff --git a/tests/utils/release-date.test.ts b/tests/utils/release-date.test.ts new file mode 100644 index 0000000..1a84eed --- /dev/null +++ b/tests/utils/release-date.test.ts @@ -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, + }); + }); +});