From 31bca0052fdbfeb52725066e7f2644a27d99f04d Mon Sep 17 00:00:00 2001 From: kikootwo Date: Thu, 22 Jan 2026 15:56:55 -0500 Subject: [PATCH] Add series fields to audiobooks and update related logic Introduces 'series' and 'seriesPart' fields to the Audiobook model and database schema. Updates API routes, file organization, and path template utilities to support series metadata. Enhances chapter merging logic, improves notification backend testing, and expands test coverage for admin and API routes. --- README.md | 1 - documentation/features/chapter-merging.md | 20 +- documentation/testing.md | 18 +- .../migration.sql | 3 + prisma/schema.prisma | 2 + .../components/RequestActionsDropdown.tsx | 5 +- .../NotificationsTab/NotificationsTab.tsx | 11 +- .../admin/settings/tabs/PathsTab/PathsTab.tsx | 8 + src/app/api/admin/notifications/test/route.ts | 84 ++- .../audiobooks/request-with-torrent/route.ts | 30 +- src/app/api/bookdate/swipe/route.ts | 32 +- src/app/api/requests/route.ts | 30 +- src/lib/integrations/audible.service.ts | 8 +- .../processors/download-torrent.processor.ts | 10 +- .../processors/organize-files.processor.ts | 2 + src/lib/utils/chapter-merger.ts | 25 +- src/lib/utils/file-organizer.ts | 16 +- src/lib/utils/path-template.util.ts | 13 +- tests/api/admin-bookdate.routes.test.ts | 23 + tests/api/admin-downloads.routes.test.ts | 49 +- tests/api/admin-job-status.routes.test.ts | 35 ++ tests/api/admin-jobs.routes.test.ts | 116 ++++ .../admin-notifications-test.routes.test.ts | 67 +++ tests/api/admin-requests.routes.test.ts | 206 +++++++ tests/api/admin-settings-core.routes.test.ts | 94 ++++ tests/api/admin-settings-tests.routes.test.ts | 103 ++++ tests/api/admin-users.routes.test.ts | 220 +++++++- tests/api/audiobooks-browse.routes.test.ts | 90 +++ .../audiobooks-request-torrent.routes.test.ts | 219 +++++++- tests/api/auth-misc.routes.test.ts | 44 ++ tests/api/auth-oidc.routes.test.ts | 21 + tests/api/auth-plex.routes.test.ts | 103 ++++ tests/api/bookdate-library.routes.test.ts | 134 +++++ tests/api/bookdate.routes.test.ts | 205 ++++++- tests/api/config.routes.test.ts | 45 ++ tests/api/requests-actions.routes.test.ts | 182 ++++++ tests/api/setup-status.routes.test.ts | 52 ++ tests/api/setup-tests.routes.test.ts | 211 +++++++ tests/app/admin-settings.page.test.tsx | 164 ++++++ tests/app/admin-users.page.test.tsx | 153 +++++ tests/app/bookdate.page.test.tsx | 294 ++++++++++ tests/app/home.page.test.tsx | 119 ++++ tests/app/login.page.test.tsx | 523 ++++++++++++++++++ tests/app/profile.page.test.tsx | 125 +++++ tests/app/requests.page.test.tsx | 91 +++ tests/app/search.page.test.tsx | 125 +++++ tests/app/select-profile.page.test.tsx | 160 ++++++ tests/app/setup.page.test.tsx | 263 +++++++++ .../setup/components/WizardLayout.test.tsx | 43 ++ tests/app/setup/initializing.page.test.tsx | 207 +++++++ .../app/setup/steps/AdminAccountStep.test.tsx | 86 +++ .../setup/steps/AudiobookshelfStep.test.tsx | 83 +++ tests/app/setup/steps/AuthMethodStep.test.tsx | 104 ++++ .../setup/steps/BackendSelectionStep.test.tsx | 73 +++ tests/app/setup/steps/BookDateStep.test.tsx | 179 ++++++ .../setup/steps/DownloadClientStep.test.tsx | 156 ++++++ tests/app/setup/steps/FinalizeStep.test.tsx | 138 +++++ tests/app/setup/steps/OIDCConfigStep.test.tsx | 290 ++++++++++ tests/app/setup/steps/PathsStep.test.tsx | 100 ++++ tests/app/setup/steps/PlexStep.test.tsx | 84 +++ tests/app/setup/steps/ProwlarrStep.test.tsx | 76 +++ .../steps/RegistrationSettingsStep.test.tsx | 54 ++ tests/app/setup/steps/ReviewStep.test.tsx | 72 +++ tests/app/setup/steps/WelcomeStep.test.tsx | 22 + tests/components/admin/FlagConfigRow.test.tsx | 41 ++ .../indexers/AvailableIndexerRow.test.tsx | 38 ++ .../admin/indexers/CategoryTreeView.test.tsx | 67 +++ .../indexers/DeleteConfirmModal.test.tsx | 48 ++ .../admin/indexers/IndexerCard.test.tsx | 34 ++ .../indexers/IndexerConfigModal.test.tsx | 107 ++++ .../admin/indexers/IndexerManagement.test.tsx | 166 ++++++ .../audiobooks/AudiobookCard.test.tsx | 178 ++++++ .../audiobooks/AudiobookDetailsModal.test.tsx | 291 ++++++++++ .../audiobooks/AudiobookGrid.test.tsx | 55 ++ tests/components/auth/ProtectedRoute.test.tsx | 90 +++ .../bookdate/BookPickerModal.test.tsx | 158 ++++++ tests/components/bookdate/CardStack.test.tsx | 89 +++ .../bookdate/LoadingScreen.test.tsx | 25 + .../bookdate/RecommendationCard.test.tsx | 135 +++++ .../bookdate/SettingsWidget.test.tsx | 205 +++++++ tests/components/layout/Header.test.tsx | 226 ++++++++ .../InteractiveTorrentSearchModal.test.tsx | 144 +++++ .../components/requests/RequestCard.test.tsx | 187 +++++++ .../components/requests/StatusBadge.test.tsx | 23 + tests/components/ui/CardSizeControls.test.tsx | 44 ++ .../ui/ChangePasswordModal.test.tsx | 121 ++++ tests/components/ui/Modal.test.tsx | 31 ++ tests/components/ui/Pagination.test.tsx | 38 ++ tests/components/ui/StickyPagination.test.tsx | 133 +++++ tests/components/ui/Toast.test.tsx | 67 +++ tests/components/ui/VersionBadge.test.tsx | 61 ++ tests/contexts/AuthContext.test.tsx | 287 ++++++++++ tests/contexts/PreferencesContext.test.tsx | 80 +++ tests/helpers/mock-auth.ts | 47 ++ tests/helpers/mock-next-navigation.ts | 47 ++ tests/helpers/render.tsx | 68 +++ tests/lib/hooks/useAudiobooks.test.tsx | 109 ++++ tests/lib/hooks/useRequests.test.tsx | 220 ++++++++ tests/lib/utils/api.test.tsx | 107 ++++ tests/lib/utils/path-template.util.test.ts | 4 +- .../organize-files.processor.test.ts | 223 ++++++++ tests/services/audiobookshelf-api.test.ts | 61 ++ tests/services/notification.service.test.ts | 3 +- tests/setup.ts | 78 ++- vitest.config.ts | 2 + 105 files changed, 10384 insertions(+), 75 deletions(-) create mode 100644 prisma/migrations/20260122000000_add_series_fields/migration.sql create mode 100644 tests/api/bookdate-library.routes.test.ts create mode 100644 tests/api/setup-status.routes.test.ts create mode 100644 tests/app/admin-settings.page.test.tsx create mode 100644 tests/app/admin-users.page.test.tsx create mode 100644 tests/app/bookdate.page.test.tsx create mode 100644 tests/app/home.page.test.tsx create mode 100644 tests/app/login.page.test.tsx create mode 100644 tests/app/profile.page.test.tsx create mode 100644 tests/app/requests.page.test.tsx create mode 100644 tests/app/search.page.test.tsx create mode 100644 tests/app/select-profile.page.test.tsx create mode 100644 tests/app/setup.page.test.tsx create mode 100644 tests/app/setup/components/WizardLayout.test.tsx create mode 100644 tests/app/setup/initializing.page.test.tsx create mode 100644 tests/app/setup/steps/AdminAccountStep.test.tsx create mode 100644 tests/app/setup/steps/AudiobookshelfStep.test.tsx create mode 100644 tests/app/setup/steps/AuthMethodStep.test.tsx create mode 100644 tests/app/setup/steps/BackendSelectionStep.test.tsx create mode 100644 tests/app/setup/steps/BookDateStep.test.tsx create mode 100644 tests/app/setup/steps/DownloadClientStep.test.tsx create mode 100644 tests/app/setup/steps/FinalizeStep.test.tsx create mode 100644 tests/app/setup/steps/OIDCConfigStep.test.tsx create mode 100644 tests/app/setup/steps/PathsStep.test.tsx create mode 100644 tests/app/setup/steps/PlexStep.test.tsx create mode 100644 tests/app/setup/steps/ProwlarrStep.test.tsx create mode 100644 tests/app/setup/steps/RegistrationSettingsStep.test.tsx create mode 100644 tests/app/setup/steps/ReviewStep.test.tsx create mode 100644 tests/app/setup/steps/WelcomeStep.test.tsx create mode 100644 tests/components/admin/FlagConfigRow.test.tsx create mode 100644 tests/components/admin/indexers/AvailableIndexerRow.test.tsx create mode 100644 tests/components/admin/indexers/CategoryTreeView.test.tsx create mode 100644 tests/components/admin/indexers/DeleteConfirmModal.test.tsx create mode 100644 tests/components/admin/indexers/IndexerCard.test.tsx create mode 100644 tests/components/admin/indexers/IndexerConfigModal.test.tsx create mode 100644 tests/components/admin/indexers/IndexerManagement.test.tsx create mode 100644 tests/components/audiobooks/AudiobookCard.test.tsx create mode 100644 tests/components/audiobooks/AudiobookDetailsModal.test.tsx create mode 100644 tests/components/audiobooks/AudiobookGrid.test.tsx create mode 100644 tests/components/auth/ProtectedRoute.test.tsx create mode 100644 tests/components/bookdate/BookPickerModal.test.tsx create mode 100644 tests/components/bookdate/CardStack.test.tsx create mode 100644 tests/components/bookdate/LoadingScreen.test.tsx create mode 100644 tests/components/bookdate/RecommendationCard.test.tsx create mode 100644 tests/components/bookdate/SettingsWidget.test.tsx create mode 100644 tests/components/layout/Header.test.tsx create mode 100644 tests/components/requests/InteractiveTorrentSearchModal.test.tsx create mode 100644 tests/components/requests/RequestCard.test.tsx create mode 100644 tests/components/requests/StatusBadge.test.tsx create mode 100644 tests/components/ui/CardSizeControls.test.tsx create mode 100644 tests/components/ui/ChangePasswordModal.test.tsx create mode 100644 tests/components/ui/Modal.test.tsx create mode 100644 tests/components/ui/Pagination.test.tsx create mode 100644 tests/components/ui/StickyPagination.test.tsx create mode 100644 tests/components/ui/Toast.test.tsx create mode 100644 tests/components/ui/VersionBadge.test.tsx create mode 100644 tests/contexts/AuthContext.test.tsx create mode 100644 tests/contexts/PreferencesContext.test.tsx create mode 100644 tests/helpers/mock-auth.ts create mode 100644 tests/helpers/mock-next-navigation.ts create mode 100644 tests/helpers/render.tsx create mode 100644 tests/lib/hooks/useAudiobooks.test.tsx create mode 100644 tests/lib/hooks/useRequests.test.tsx create mode 100644 tests/lib/utils/api.test.tsx diff --git a/README.md b/README.md index 8748b93..2164ad0 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ [![License](https://img.shields.io/badge/License-AGPL%20v3-blue.svg?style=for-the-badge)](https://www.gnu.org/licenses/agpl-3.0) [![GitHub Stars](https://img.shields.io/github/stars/kikootwo/readmeabook?style=for-the-badge&logo=github)](https://github.com/kikootwo/readmeabook/stargazers) [![Discord](https://img.shields.io/discord/1450562177277755464?style=for-the-badge&logo=discord&logoColor=white&label=Discord)](https://discord.gg/kaw6jKbKts) - *Radarr/Sonarr + Overseerr for audiobooks, all in one* diff --git a/documentation/features/chapter-merging.md b/documentation/features/chapter-merging.md index 19d9443..b98290a 100644 --- a/documentation/features/chapter-merging.md +++ b/documentation/features/chapter-merging.md @@ -164,14 +164,14 @@ ffmpeg -y -f concat -safe 0 -i filelist.txt \ **For MP3 files (requires conversion - v2 enhanced):** ```bash # Re-encode to M4B (AAC) with quality preservation -# Uses libfdk_aac if available (higher quality) or native aac +# Uses libfdk_aac if available (higher quality) or native aac with VBR ffmpeg -y -f concat -safe 0 -i filelist.txt \ -i chapters.txt \ -map_metadata 1 \ -map 0:a \ - -c:a libfdk_aac -vbr 4 \ # High quality AAC (or: -c:a aac -b:a -profile:a aac_low) + -c:a libfdk_aac -vbr 4 \ # High quality AAC (or: -c:a aac -q:a -profile:a aac_low) -movflags +faststart \ # CRITICAL: Instant playback - -fflags +genpts \ # Fix timestamps + -fflags +genpts \ # Fix timestamps (regenerates presentation timestamps) -avoid_negative_ts make_zero \ # Handle edge cases -max_muxing_queue_size 9999 \ # Long file support -metadata title="Book Title" \ @@ -182,16 +182,18 @@ ffmpeg -y -f concat -safe 0 -i filelist.txt \ **Quality Settings (MP3 → M4B - v2):** - **Bitrate:** Matches source average (64-320kbps range) - - Example: 128kbps MP3 source → 128kbps AAC output - - Example: 192kbps MP3 source → 192kbps AAC output -- **Encoder:** libfdk_aac (VBR mode 4, high quality) if available, else native aac + - Example: 128kbps MP3 source → ~128kbps AAC output + - Example: 192kbps MP3 source → ~192kbps AAC output +- **Encoder:** libfdk_aac (VBR mode 4, high quality) if available, else native aac with VBR + - Native AAC uses variable bitrate for better quality/efficiency + - Quality mapped dynamically: 64k→q1.0, 128k→q2.0, 192k→q3.0, 256k→q4.0 - **Profile:** AAC-LC (maximum compatibility) - **Sampling rate:** Preserved from source - **Channels:** Preserved (mono/stereo) **Critical Flags (v2):** - **`-movflags +faststart`**: Moves moov atom to file beginning → instant playback (fixes 1-min delay) -- **`-fflags +genpts`**: Regenerates presentation timestamps → fixes seeking/timing issues +- **`-fflags +genpts`**: Regenerates presentation timestamps for audio/video → fixes seeking/timing issues at concat boundaries - **`-avoid_negative_ts make_zero`**: Handles negative timestamps at concat boundaries - **`-max_muxing_queue_size 9999`**: Prevents buffer overflow on long audiobooks (16h+) @@ -466,8 +468,8 @@ timeout = 5 minutes + (chapter_count * 30 seconds) All merged files are validated before marked successful: 1. **Duration Check:** Expected vs actual duration (within 2% tolerance) -2. **Decode Test:** FFmpeg attempts to decode first 10 seconds (catches corruption) -3. **Size Check:** File size reasonable for duration (~0.5MB/min minimum) +2. **Decode Test:** FFmpeg attempts to decode first and last 10 seconds (catches corruption/truncation) +3. **Size Check:** File size reasonable for duration (~0.4MB/min minimum, accommodates 64kbps encoding) **If validation fails:** - Corrupt file is deleted diff --git a/documentation/testing.md b/documentation/testing.md index 9472312..792e549 100644 --- a/documentation/testing.md +++ b/documentation/testing.md @@ -1,17 +1,19 @@ # Testing -**Status:** ⏳ In Progress | Backend unit testing framework (Vitest) +**Status:** ƒ?3 In Progress | Backend + frontend unit testing framework (Vitest) ## Overview -Unit tests for backend logic with isolated mocks (Prisma, integrations, queue). +Backend unit tests (Node) and frontend component tests (jsdom) with isolated mocks and deterministic helpers. ## Key Details -- **Runner:** Vitest (`vitest.config.ts`, Node environment) -- **Setup:** `tests/setup.ts` sets `NODE_ENV=test`, `TZ=UTC`, blocks unmocked fetch -- **Helpers:** `tests/helpers/prisma.ts`, `tests/helpers/job-queue.ts` +- **Runner:** Vitest (`vitest.config.ts`) +- **Environments:** Node for `*.test.ts`, jsdom for `*.test.tsx` via `environmentMatchGlobs` +- **Setup:** `tests/setup.ts` sets `NODE_ENV=test`, `TZ=UTC`, jest-dom, DOM polyfills, Next.js `Link/Image` mocks +- **Frontend helpers:** `tests/helpers/render.tsx`, `tests/helpers/mock-auth.ts`, `tests/helpers/mock-next-navigation.ts` +- **Backend helpers:** `tests/helpers/prisma.ts`, `tests/helpers/job-queue.ts` - **GitHub Actions:** Manual workflow `.github/workflows/manual-tests.yml` runs `npm test` - **Coverage:** `npm run test:coverage` (reports in `coverage/`) -- **Scope:** Backend unit tests only; no real network or services +- **Scope:** Unit tests only; no real network or services ## API/Interfaces ``` @@ -21,8 +23,10 @@ npm run test:coverage ``` ## Critical Issues -- API route unit tests are incomplete; add route-level mocks before enforcing coverage. +- Frontend coverage not yet enforced; expand component/page tests before adding coverage gates. ## Related +- [frontend/components.md](frontend/components.md) +- [frontend/routing-auth.md](frontend/routing-auth.md) - [backend/services/jobs.md](backend/services/jobs.md) - [backend/services/scheduler.md](backend/services/scheduler.md) diff --git a/prisma/migrations/20260122000000_add_series_fields/migration.sql b/prisma/migrations/20260122000000_add_series_fields/migration.sql new file mode 100644 index 0000000..9c34d34 --- /dev/null +++ b/prisma/migrations/20260122000000_add_series_fields/migration.sql @@ -0,0 +1,3 @@ +-- AddSeriesFields +ALTER TABLE "audiobooks" ADD COLUMN "series" TEXT; +ALTER TABLE "audiobooks" ADD COLUMN "series_part" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 388f563..9069665 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -168,6 +168,8 @@ model Audiobook { description String? @db.Text coverArtUrl String? @map("cover_art_url") @db.Text year Int? // Release year extracted from releaseDate + series String? // Book series name (e.g., "The Mistborn Saga") + seriesPart String? @map("series_part") // Series position (e.g., "1", "1.5", "Book 1") // Request tracking status String @default("requested") // requested, downloading, processing, completed, failed diff --git a/src/app/admin/components/RequestActionsDropdown.tsx b/src/app/admin/components/RequestActionsDropdown.tsx index ad3262d..f6bf79d 100644 --- a/src/app/admin/components/RequestActionsDropdown.tsx +++ b/src/app/admin/components/RequestActionsDropdown.tsx @@ -45,7 +45,10 @@ export function RequestActionsDropdown({ const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status); const canCancel = ['pending', 'searching', 'downloading'].includes(request.status); const canDelete = true; // Admins can always delete - const canViewSource = !!request.torrentUrl && ['downloading', 'processing', 'downloaded', 'available'].includes(request.status); + // Only show "View Source" if we have a valid indexer page URL (not a magnet link) + const canViewSource = !!request.torrentUrl && + !request.torrentUrl.startsWith('magnet:') && + ['downloading', 'processing', 'downloaded', 'available'].includes(request.status); const canFetchEbook = ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status); // Close dropdown when clicking outside diff --git a/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx b/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx index 1dc9d4f..d39a9af 100644 --- a/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx +++ b/src/app/admin/settings/tabs/NotificationsTab/NotificationsTab.tsx @@ -117,12 +117,15 @@ export function NotificationsTab() { setIsTesting(true); setTestResult(null); + // In edit mode, use backend ID to test with real config (masked values won't work) + // In add mode, use the form config directly + const testPayload = modalState.mode === 'edit' && modalState.backend + ? { backendId: modalState.backend.id } + : { type: modalState.selectedType, config: formData.config }; + const response = await fetchWithAuth('/api/admin/notifications/test', { method: 'POST', - body: JSON.stringify({ - type: modalState.selectedType, - config: formData.config, - }), + body: JSON.stringify(testPayload), }); const data = await response.json(); diff --git a/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx b/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx index e34624d..8b04194 100644 --- a/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx +++ b/src/app/admin/settings/tabs/PathsTab/PathsTab.tsx @@ -137,6 +137,14 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) {'{asin}'} - Audible ASIN +
+ {'{series}'} + - Book series name +
+
+ {'{seriesPart}'} + - Series part/position +
diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts index 1ca2c7b..81f2d74 100644 --- a/src/app/api/admin/notifications/test/route.ts +++ b/src/app/api/admin/notifications/test/route.ts @@ -8,12 +8,29 @@ import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middlewar import { getNotificationService, NotificationBackendType, NotificationPayload } from '@/lib/services/notification.service'; import { RMABLogger } from '@/lib/utils/logger'; import { z } from 'zod'; +import { prisma } from '@/lib/db'; const logger = RMABLogger.create('API.Admin.Notifications.Test'); -const TestNotificationSchema = z.object({ - type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']), - config: z.record(z.any()), +const TestNotificationSchema = z.discriminatedUnion('mode', [ + // Test existing backend by ID (uses stored config) + z.object({ + mode: z.literal('backend'), + backendId: z.string(), + }), + // Test new config before saving + z.object({ + mode: z.literal('config'), + type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']), + config: z.record(z.any()), + }), +]); + +// Support legacy format without mode +const LegacyTestNotificationSchema = z.object({ + backendId: z.string().optional(), + type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']).optional(), + config: z.record(z.any()).optional(), }); /** @@ -25,12 +42,67 @@ export async function POST(request: NextRequest) { return requireAdmin(req, async () => { try { const body = await request.json(); - const { type, config } = TestNotificationSchema.parse(body); + + // Support legacy format for backward compatibility + const legacyParsed = LegacyTestNotificationSchema.safeParse(body); + + let type: NotificationBackendType; + let encryptedConfig: any; const notificationService = getNotificationService(); - // Encrypt config values - const encryptedConfig = notificationService.encryptConfig(type, config); + if (legacyParsed.success) { + // Legacy format + if (legacyParsed.data.backendId) { + // Test existing backend + const backend = await prisma.notificationBackend.findUnique({ + where: { id: legacyParsed.data.backendId }, + }); + + if (!backend) { + return NextResponse.json( + { error: 'NotFound', message: 'Backend not found' }, + { status: 404 } + ); + } + + type = backend.type as NotificationBackendType; + encryptedConfig = backend.config; // Already encrypted in DB + } else if (legacyParsed.data.type && legacyParsed.data.config) { + // Test new config + type = legacyParsed.data.type as NotificationBackendType; + encryptedConfig = notificationService.encryptConfig(type, legacyParsed.data.config); + } else { + return NextResponse.json( + { error: 'ValidationError', message: 'Must provide either backendId or type+config' }, + { status: 400 } + ); + } + } else { + // New format with discriminated union + const parsed = TestNotificationSchema.parse(body); + + if (parsed.mode === 'backend') { + // Test existing backend + const backend = await prisma.notificationBackend.findUnique({ + where: { id: parsed.backendId }, + }); + + if (!backend) { + return NextResponse.json( + { error: 'NotFound', message: 'Backend not found' }, + { status: 404 } + ); + } + + type = backend.type as NotificationBackendType; + encryptedConfig = backend.config; // Already encrypted in DB + } else { + // Test new config + type = parsed.type; + encryptedConfig = notificationService.encryptConfig(type, parsed.config); + } + } // Create test payload const testPayload: NotificationPayload = { diff --git a/src/app/api/audiobooks/request-with-torrent/route.ts b/src/app/api/audiobooks/request-with-torrent/route.ts index 3aa6426..b2cdab7 100644 --- a/src/app/api/audiobooks/request-with-torrent/route.ts +++ b/src/app/api/audiobooks/request-with-torrent/route.ts @@ -113,8 +113,10 @@ export async function POST(request: NextRequest) { ); } - // Fetch full details from Audnexus to get releaseDate and year + // Fetch full details from Audnexus to get releaseDate, year, and series let year: number | undefined; + let series: string | undefined; + let seriesPart: string | undefined; try { const audibleService = getAudibleService(); const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin); @@ -130,6 +132,16 @@ export async function POST(request: NextRequest) { logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`); } } + + // Extract series data + if (audnexusData?.series) { + series = audnexusData.series; + logger.debug(`Extracted series: ${series}`); + } + if (audnexusData?.seriesPart) { + seriesPart = audnexusData.seriesPart; + logger.debug(`Extracted seriesPart: ${seriesPart}`); + } } catch (error) { logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`); } @@ -150,17 +162,23 @@ export async function POST(request: NextRequest) { description: audiobook.description, coverArtUrl: audiobook.coverArtUrl, year, + series, + seriesPart, status: 'requested', }, }); - logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}`); - } else if (year) { - // Always update year if we have it from Audnexus (even if audiobook already has one) + logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}, series: ${series || 'none'}`); + } else if (year || series || seriesPart) { + // Always update year/series if we have them from Audnexus (even if audiobook already has them) audiobookRecord = await prisma.audiobook.update({ where: { id: audiobookRecord.id }, - data: { year }, + data: { + ...(year && { year }), + ...(series && { series }), + ...(seriesPart && { seriesPart }), + }, }); - logger.debug(`Updated audiobook ${audiobookRecord.id} with year ${year}`); + logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`); } // Check if user already has an active (non-deleted) request for this audiobook diff --git a/src/app/api/bookdate/swipe/route.ts b/src/app/api/bookdate/swipe/route.ts index 2f0bfbb..d2fa39d 100644 --- a/src/app/api/bookdate/swipe/route.ts +++ b/src/app/api/bookdate/swipe/route.ts @@ -63,8 +63,10 @@ async function handler(req: AuthenticatedRequest) { // If swiped right and not marked as known, create request if (action === 'right' && !markedAsKnown && recommendation.audnexusAsin) { try { - // Fetch full details from Audnexus to get releaseDate and year + // Fetch full details from Audnexus to get releaseDate, year, and series let year: number | undefined; + let series: string | undefined; + let seriesPart: string | undefined; try { const audibleService = getAudibleService(); const audnexusData = await audibleService.getAudiobookDetails(recommendation.audnexusAsin); @@ -80,6 +82,16 @@ async function handler(req: AuthenticatedRequest) { logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`); } } + + // Extract series data + if (audnexusData?.series) { + series = audnexusData.series; + logger.debug(`Extracted series: ${series}`); + } + if (audnexusData?.seriesPart) { + seriesPart = audnexusData.seriesPart; + logger.debug(`Extracted seriesPart: ${seriesPart}`); + } } catch (error) { logger.warn(`Failed to fetch Audnexus data for ASIN ${recommendation.audnexusAsin}: ${error instanceof Error ? error.message : 'Unknown error'}`); } @@ -89,7 +101,7 @@ async function handler(req: AuthenticatedRequest) { where: { audibleAsin: recommendation.audnexusAsin }, }); - // If not, create it with year + // If not, create it with year and series if (!audiobook) { audiobook = await prisma.audiobook.create({ data: { @@ -100,17 +112,23 @@ async function handler(req: AuthenticatedRequest) { description: recommendation.description, coverArtUrl: recommendation.coverUrl, year, + series, + seriesPart, status: 'requested', }, }); - logger.debug(`Created audiobook ${audiobook.id} with year: ${year || 'none'}`); - } else if (year) { - // Always update year if we have it from Audnexus (even if audiobook already has one) + logger.debug(`Created audiobook ${audiobook.id} with year: ${year || 'none'}, series: ${series || 'none'}`); + } else if (year || series || seriesPart) { + // Always update year/series if we have them from Audnexus (even if audiobook already has them) audiobook = await prisma.audiobook.update({ where: { id: audiobook.id }, - data: { year }, + data: { + ...(year && { year }), + ...(series && { series }), + ...(seriesPart && { seriesPart }), + }, }); - logger.debug(`Updated audiobook ${audiobook.id} with year ${year}`); + logger.debug(`Updated audiobook ${audiobook.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`); } // Create request (if not already exists) diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index 2d876f0..c5914c9 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -97,8 +97,10 @@ export async function POST(request: NextRequest) { ); } - // Fetch full details from Audnexus to get releaseDate and year + // Fetch full details from Audnexus to get releaseDate, year, and series let year: number | undefined; + let series: string | undefined; + let seriesPart: string | undefined; try { const audibleService = getAudibleService(); const audnexusData = await audibleService.getAudiobookDetails(audiobook.asin); @@ -114,6 +116,16 @@ export async function POST(request: NextRequest) { logger.warn(`Failed to parse Audnexus releaseDate "${audnexusData.releaseDate}": ${error instanceof Error ? error.message : 'Unknown error'}`); } } + + // Extract series data + if (audnexusData?.series) { + series = audnexusData.series; + logger.debug(`Extracted series: ${series}`); + } + if (audnexusData?.seriesPart) { + seriesPart = audnexusData.seriesPart; + logger.debug(`Extracted seriesPart: ${seriesPart}`); + } } catch (error) { logger.warn(`Failed to fetch Audnexus data for ASIN ${audiobook.asin}: ${error instanceof Error ? error.message : 'Unknown error'}`); } @@ -134,17 +146,23 @@ export async function POST(request: NextRequest) { description: audiobook.description, coverArtUrl: audiobook.coverArtUrl, year, + series, + seriesPart, status: 'requested', }, }); - logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}`); - } else if (year) { - // Always update year if we have it from Audnexus (even if audiobook already has one) + logger.debug(`Created audiobook ${audiobookRecord.id} with year: ${year || 'none'}, series: ${series || 'none'}`); + } else if (year || series || seriesPart) { + // Always update year/series if we have them from Audnexus (even if audiobook already has them) audiobookRecord = await prisma.audiobook.update({ where: { id: audiobookRecord.id }, - data: { year }, + data: { + ...(year && { year }), + ...(series && { series }), + ...(seriesPart && { seriesPart }), + }, }); - logger.debug(`Updated audiobook ${audiobookRecord.id} with year ${year}`); + logger.debug(`Updated audiobook ${audiobookRecord.id} with year: ${year || 'unchanged'}, series: ${series || 'unchanged'}`); } // Check if user already has an active (non-deleted) request for this audiobook diff --git a/src/lib/integrations/audible.service.ts b/src/lib/integrations/audible.service.ts index 53c0ab9..059d0e9 100644 --- a/src/lib/integrations/audible.service.ts +++ b/src/lib/integrations/audible.service.ts @@ -23,6 +23,8 @@ export interface AudibleAudiobook { releaseDate?: string; rating?: number; genres?: string[]; + series?: string; + seriesPart?: string; } export interface AudibleSearchResult { @@ -492,6 +494,8 @@ export class AudibleService { releaseDate: data.releaseDate || undefined, rating: data.rating ? parseFloat(data.rating) : undefined, genres: data.genres?.map((g: any) => typeof g === 'string' ? g : g.name).slice(0, 5) || undefined, + series: data.seriesPrimary?.name || undefined, + seriesPart: data.seriesPrimary?.position || undefined, }; // Ensure cover art URL is high quality @@ -506,7 +510,9 @@ export class AudibleService { descLength: result.description?.length || 0, duration: result.durationMinutes, rating: result.rating, - genreCount: result.genres?.length || 0 + genreCount: result.genres?.length || 0, + series: result.series, + seriesPart: result.seriesPart }); return result; diff --git a/src/lib/processors/download-torrent.processor.ts b/src/lib/processors/download-torrent.processor.ts index f17aafa..a0429ad 100644 --- a/src/lib/processors/download-torrent.processor.ts +++ b/src/lib/processors/download-torrent.processor.ts @@ -60,6 +60,9 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P logger.info(`NZB added with ID: ${downloadClientId}`); // Create DownloadHistory record + // Determine indexer page URL - exclude magnet links from guid fallback + const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid); + const downloadHistory = await prisma.downloadHistory.create({ data: { requestId, @@ -69,7 +72,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P torrentName: torrent.title, nzbId: downloadClientId, // Store NZB ID torrentSizeBytes: torrent.size, - torrentUrl: torrent.infoUrl || torrent.guid, // Indexer page URL (prefer infoUrl, fallback to guid) + torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link) magnetLink: torrent.downloadUrl, // Download URL (.nzb file) seeders: torrent.seeders || 0, // Usenet doesn't have seeders, but include for consistency leechers: 0, @@ -121,6 +124,9 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P logger.info(`Torrent added with hash: ${downloadClientId}`); // Create DownloadHistory record + // Determine indexer page URL - exclude magnet links from guid fallback + const indexerPageUrl = torrent.infoUrl || (torrent.guid?.startsWith('magnet:') ? null : torrent.guid); + const downloadHistory = await prisma.downloadHistory.create({ data: { requestId, @@ -130,7 +136,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P torrentName: torrent.title, torrentHash: torrent.infoHash || downloadClientId, // Store torrent hash torrentSizeBytes: torrent.size, - torrentUrl: torrent.infoUrl || torrent.guid, // Indexer page URL (prefer infoUrl, fallback to guid) + torrentUrl: indexerPageUrl, // Indexer page URL (only if available and not a magnet/download link) magnetLink: torrent.downloadUrl, seeders: torrent.seeders || 0, leechers: torrent.leechers || 0, diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index 1cfd640..efe3454 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -94,6 +94,8 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi coverArtUrl: audiobook.coverArtUrl || undefined, asin: audiobook.audibleAsin || undefined, year, + series: audiobook.series || undefined, + seriesPart: audiobook.seriesPart || undefined, }, template, jobId ? { jobId, context: 'FileOrganizer' } : undefined diff --git a/src/lib/utils/chapter-merger.ts b/src/lib/utils/chapter-merger.ts index 9039800..a17ad81 100644 --- a/src/lib/utils/chapter-merger.ts +++ b/src/lib/utils/chapter-merger.ts @@ -447,6 +447,23 @@ function determineOutputBitrate(chapters: ChapterFile[]): string { return `${finalBitrate}k`; } +/** + * Map bitrate to native AAC VBR quality value + * Quality range: 0.1-5 (higher = better quality/larger file) + */ +function bitrateToVbrQuality(bitrateStr: string): number { + const bitrate = parseInt(bitrateStr.replace('k', '')); + + // Approximate mapping based on AAC VBR behavior + if (bitrate <= 64) return 1.0; // ~64kbps + if (bitrate <= 96) return 1.5; // ~96kbps + if (bitrate <= 128) return 2.0; // ~128kbps + if (bitrate <= 160) return 2.5; // ~160kbps + if (bitrate <= 192) return 3.0; // ~192kbps + if (bitrate <= 256) return 4.0; // ~256kbps + return 4.5; // ~320kbps+ (max quality) +} + /** * Check if libfdk_aac encoder is available (higher quality than native AAC) */ @@ -640,10 +657,12 @@ export async function mergeChapters( args.push('-vbr', '4'); // VBR mode 4 (~128-160kbps, high quality) await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using libfdk_aac (high quality VBR, target ~${bitrate})`); } else { + // Use VBR for better quality at same average bitrate + const vbrQuality = bitrateToVbrQuality(bitrate); args.push('-c:a', 'aac'); - args.push('-b:a', bitrate); + args.push('-q:a', vbrQuality.toString()); args.push('-profile:a', 'aac_low'); // AAC-LC profile for maximum compatibility - await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using native AAC at ${bitrate}`); + await logger?.info(`Merge strategy: Re-encoding MP3 → AAC/M4B using native AAC VBR (quality ${vbrQuality}, target ~${bitrate})`); } } else { // M4A/M4B -> M4B can use codec copy (fast, lossless) @@ -838,7 +857,7 @@ async function validateMergedFile( const stats = await fs.stat(outputPath); const sizeMB = stats.size / 1024 / 1024; const durationMinutes = expectedDuration / 1000 / 60; - const expectedMinSize = durationMinutes * 0.5; // ~0.5MB per minute minimum for compressed audio + const expectedMinSize = durationMinutes * 0.4; // ~0.4MB per minute minimum (accommodates 64kbps encoding) if (sizeMB < expectedMinSize) { return { diff --git a/src/lib/utils/file-organizer.ts b/src/lib/utils/file-organizer.ts index 18112cc..9a83b0d 100644 --- a/src/lib/utils/file-organizer.ts +++ b/src/lib/utils/file-organizer.ts @@ -30,6 +30,8 @@ export interface AudiobookMetadata { year?: number; coverArtUrl?: string; asin?: string; + series?: string; + seriesPart?: string; } export interface OrganizationResult { @@ -275,7 +277,9 @@ export class FileOrganizer { audiobook.title, audiobook.narrator, audiobook.asin, - audiobook.year + audiobook.year, + audiobook.series, + audiobook.seriesPart ); await logger?.info(`Target path: ${targetPath}`); @@ -556,7 +560,9 @@ export class FileOrganizer { title: string, narrator?: string, asin?: string, - year?: number + year?: number, + series?: string, + seriesPart?: string ): string { const variables: TemplateVariables = { author, @@ -564,6 +570,8 @@ export class FileOrganizer { narrator, asin, year, + series, + seriesPart, }; const relativePath = substituteTemplate(template, variables); @@ -713,7 +721,7 @@ export async function getFileOrganizer(): Promise { export function buildAudiobookPath( baseDir: string, template: string, - variables: { author: string; title: string; narrator?: string; asin?: string; year?: number } + variables: { author: string; title: string; narrator?: string; asin?: string; year?: number; series?: string; seriesPart?: string } ): string { const templateVars: TemplateVariables = { author: variables.author, @@ -721,6 +729,8 @@ export function buildAudiobookPath( narrator: variables.narrator, asin: variables.asin, year: variables.year, + series: variables.series, + seriesPart: variables.seriesPart, }; const relativePath = substituteTemplate(template, templateVars); diff --git a/src/lib/utils/path-template.util.ts b/src/lib/utils/path-template.util.ts index 23f8517..320051c 100644 --- a/src/lib/utils/path-template.util.ts +++ b/src/lib/utils/path-template.util.ts @@ -15,6 +15,8 @@ export interface TemplateVariables { narrator?: string; asin?: string; year?: number; + series?: string; + seriesPart?: string; } /** @@ -28,7 +30,7 @@ export interface ValidationResult { /** * Supported template variable names */ -const VALID_VARIABLES = ['author', 'title', 'narrator', 'asin', 'year']; +const VALID_VARIABLES = ['author', 'title', 'narrator', 'asin', 'year', 'series', 'seriesPart']; /** * Invalid file path characters (outside of template variables) @@ -228,14 +230,18 @@ export function generateMockPreviews(template: string): string[] { title: 'Mistborn: The Final Empire', narrator: 'Michael Kramer', asin: 'B002UZMLXM', - year: 2006 + year: 2006, + series: 'The Mistborn Saga', + seriesPart: '1' }, { author: 'Douglas Adams', title: "The Hitchhiker's Guide to the Galaxy", narrator: 'Stephen Fry', asin: 'B0009JKV9W', - year: 2005 + year: 2005, + series: "Hitchhiker's Guide", + seriesPart: '1' }, { author: 'Andy Weir', @@ -243,6 +249,7 @@ export function generateMockPreviews(template: string): string[] { // No narrator for this example asin: 'B08G9PRS1K', year: 2021 + // No series data - to test empty handling } ]; diff --git a/tests/api/admin-bookdate.routes.test.ts b/tests/api/admin-bookdate.routes.test.ts index 89ea21a..80042db 100644 --- a/tests/api/admin-bookdate.routes.test.ts +++ b/tests/api/admin-bookdate.routes.test.ts @@ -44,6 +44,29 @@ describe('Admin BookDate toggle route', () => { expect(payload.isEnabled).toBe(true); expect(prismaMock.bookDateConfig.updateMany).toHaveBeenCalledWith({ data: { isEnabled: true } }); }); + + it('rejects non-boolean toggle values', async () => { + authRequest.json.mockResolvedValue({ isEnabled: 'yes' }); + + const { PATCH } = await import('@/app/api/admin/bookdate/toggle/route'); + const response = await PATCH({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/must be a boolean/); + }); + + it('returns 500 when toggle fails', async () => { + authRequest.json.mockResolvedValue({ isEnabled: false }); + prismaMock.bookDateConfig.updateMany.mockRejectedValue(new Error('toggle failed')); + + const { PATCH } = await import('@/app/api/admin/bookdate/toggle/route'); + const response = await PATCH({} as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/toggle failed/); + }); }); diff --git a/tests/api/admin-downloads.routes.test.ts b/tests/api/admin-downloads.routes.test.ts index 131e645..a9d582b 100644 --- a/tests/api/admin-downloads.routes.test.ts +++ b/tests/api/admin-downloads.routes.test.ts @@ -13,6 +13,7 @@ const requireAuthMock = vi.hoisted(() => vi.fn()); const requireAdminMock = vi.hoisted(() => vi.fn()); const configServiceMock = vi.hoisted(() => ({ get: vi.fn() })); const qbittorrentMock = vi.hoisted(() => ({ getTorrent: vi.fn() })); +const sabnzbdMock = vi.hoisted(() => ({ getNZB: vi.fn() })); vi.mock('@/lib/db', () => ({ prisma: prismaMock, @@ -32,7 +33,7 @@ vi.mock('@/lib/integrations/qbittorrent.service', () => ({ })); vi.mock('@/lib/integrations/sabnzbd.service', () => ({ - getSABnzbdService: async () => ({ getNZB: vi.fn() }), + getSABnzbdService: async () => sabnzbdMock, })); describe('Admin downloads route', () => { @@ -65,6 +66,52 @@ describe('Admin downloads route', () => { expect(payload.downloads[0].speed).toBe(123); expect(payload.downloads[0].torrentName).toBe('Torrent'); }); + + it('returns formatted active downloads for SABnzbd', async () => { + prismaMock.request.findMany.mockResolvedValueOnce([ + { + id: 'req-2', + status: 'downloading', + progress: 20, + updatedAt: new Date(), + audiobook: { title: 'Title', author: 'Author' }, + user: { plexUsername: 'user' }, + downloadHistory: [{ nzbId: 'nzb-1', torrentName: 'NZB', downloadStatus: 'downloading' }], + }, + ]); + configServiceMock.get.mockResolvedValueOnce('sabnzbd'); + sabnzbdMock.getNZB.mockResolvedValueOnce({ downloadSpeed: 555, timeLeft: 120 }); + + const { GET } = await import('@/app/api/admin/downloads/active/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.downloads[0].speed).toBe(555); + expect(payload.downloads[0].eta).toBe(120); + }); + + it('returns defaults when download client lookup fails', async () => { + prismaMock.request.findMany.mockResolvedValueOnce([ + { + id: 'req-3', + status: 'downloading', + progress: 80, + updatedAt: new Date(), + audiobook: { title: 'Title', author: 'Author' }, + user: { plexUsername: 'user' }, + downloadHistory: [{ torrentHash: 'hash', torrentName: 'Torrent', downloadStatus: 'downloading' }], + }, + ]); + configServiceMock.get.mockResolvedValueOnce('qbittorrent'); + qbittorrentMock.getTorrent.mockRejectedValueOnce(new Error('client down')); + + const { GET } = await import('@/app/api/admin/downloads/active/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.downloads[0].speed).toBe(0); + expect(payload.downloads[0].eta).toBeNull(); + }); }); diff --git a/tests/api/admin-job-status.routes.test.ts b/tests/api/admin-job-status.routes.test.ts index 17365b4..aaff295 100644 --- a/tests/api/admin-job-status.routes.test.ts +++ b/tests/api/admin-job-status.routes.test.ts @@ -58,6 +58,41 @@ describe('Admin job status route', () => { expect(payload.success).toBe(true); expect(payload.job.status).toBe('completed'); }); + + it('rejects non-admin tokens', async () => { + verifyAccessTokenMock.mockReturnValue({ role: 'user' }); + + const { GET } = await import('@/app/api/admin/job-status/[id]/route'); + const response = await GET(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: '1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/Admin access required/); + }); + + it('returns 404 when job is missing', async () => { + verifyAccessTokenMock.mockReturnValue({ role: 'admin' }); + jobQueueMock.getJob.mockResolvedValue(null); + + const { GET } = await import('@/app/api/admin/job-status/[id]/route'); + const response = await GET(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('Job not found'); + }); + + it('returns 500 when job lookup fails', async () => { + verifyAccessTokenMock.mockReturnValue({ role: 'admin' }); + jobQueueMock.getJob.mockRejectedValue(new Error('lookup failed')); + + const { GET } = await import('@/app/api/admin/job-status/[id]/route'); + const response = await GET(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: '1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('InternalError'); + }); }); diff --git a/tests/api/admin-jobs.routes.test.ts b/tests/api/admin-jobs.routes.test.ts index 6ff01a0..0603211 100644 --- a/tests/api/admin-jobs.routes.test.ts +++ b/tests/api/admin-jobs.routes.test.ts @@ -45,6 +45,35 @@ describe('Admin jobs routes', () => { expect(payload.jobs).toHaveLength(1); }); + it('rejects job list when missing token', async () => { + const { GET } = await import('@/app/api/admin/jobs/route'); + const response = await GET(makeRequest() as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('rejects job list for non-admin users', async () => { + verifyAccessTokenMock.mockReturnValue({ role: 'user' }); + const { GET } = await import('@/app/api/admin/jobs/route'); + const response = await GET(makeRequest('Bearer token') as any); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/Admin access required/); + }); + + it('returns 500 when job list fails', async () => { + schedulerMock.getScheduledJobs.mockRejectedValue(new Error('boom')); + const { GET } = await import('@/app/api/admin/jobs/route'); + const response = await GET(makeRequest('Bearer token') as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('InternalError'); + }); + it('creates a scheduled job', async () => { schedulerMock.createScheduledJob.mockResolvedValue({ id: 'job-2' }); const { POST } = await import('@/app/api/admin/jobs/route'); @@ -55,6 +84,35 @@ describe('Admin jobs routes', () => { expect(payload.job.id).toBe('job-2'); }); + it('rejects job creation when missing token', async () => { + const { POST } = await import('@/app/api/admin/jobs/route'); + const response = await POST(makeRequest() as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('rejects job creation for non-admin users', async () => { + verifyAccessTokenMock.mockReturnValue({ role: 'user' }); + const { POST } = await import('@/app/api/admin/jobs/route'); + const response = await POST(makeRequest('Bearer token') as any); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/Admin access required/); + }); + + it('returns 500 when job creation fails', async () => { + schedulerMock.createScheduledJob.mockRejectedValue(new Error('create failed')); + const { POST } = await import('@/app/api/admin/jobs/route'); + const response = await POST(makeRequest('Bearer token', { name: 'Job' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.message).toMatch(/create failed/); + }); + it('updates a scheduled job', async () => { schedulerMock.updateScheduledJob.mockResolvedValue({ id: 'job-3' }); const { PUT } = await import('@/app/api/admin/jobs/[id]/route'); @@ -65,6 +123,35 @@ describe('Admin jobs routes', () => { expect(payload.success).toBe(true); }); + it('rejects job updates when missing token', async () => { + const { PUT } = await import('@/app/api/admin/jobs/[id]/route'); + const response = await PUT(makeRequest() as any, { params: Promise.resolve({ id: 'job-3' }) }); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('rejects job updates for non-admin users', async () => { + verifyAccessTokenMock.mockReturnValue({ role: 'user' }); + const { PUT } = await import('@/app/api/admin/jobs/[id]/route'); + const response = await PUT(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'job-3' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/Admin access required/); + }); + + it('returns 500 when job update fails', async () => { + schedulerMock.updateScheduledJob.mockRejectedValue(new Error('update failed')); + const { PUT } = await import('@/app/api/admin/jobs/[id]/route'); + const response = await PUT(makeRequest('Bearer token', { name: 'Job' }) as any, { params: Promise.resolve({ id: 'job-3' }) }); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.message).toMatch(/update failed/); + }); + it('deletes a scheduled job', async () => { schedulerMock.deleteScheduledJob.mockResolvedValue(undefined); const { DELETE } = await import('@/app/api/admin/jobs/[id]/route'); @@ -75,6 +162,35 @@ describe('Admin jobs routes', () => { expect(payload.success).toBe(true); }); + it('rejects job deletion when missing token', async () => { + const { DELETE } = await import('@/app/api/admin/jobs/[id]/route'); + const response = await DELETE(makeRequest() as any, { params: Promise.resolve({ id: 'job-4' }) }); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('rejects job deletion for non-admin users', async () => { + verifyAccessTokenMock.mockReturnValue({ role: 'user' }); + const { DELETE } = await import('@/app/api/admin/jobs/[id]/route'); + const response = await DELETE(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'job-4' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/Admin access required/); + }); + + it('returns 500 when job deletion fails', async () => { + schedulerMock.deleteScheduledJob.mockRejectedValue(new Error('delete failed')); + const { DELETE } = await import('@/app/api/admin/jobs/[id]/route'); + const response = await DELETE(makeRequest('Bearer token') as any, { params: Promise.resolve({ id: 'job-4' }) }); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.message).toMatch(/delete failed/); + }); + it('triggers a scheduled job', async () => { schedulerMock.triggerJobNow.mockResolvedValue('job-5'); const { POST } = await import('@/app/api/admin/jobs/[id]/trigger/route'); diff --git a/tests/api/admin-notifications-test.routes.test.ts b/tests/api/admin-notifications-test.routes.test.ts index a58fdf7..3dc5f3b 100644 --- a/tests/api/admin-notifications-test.routes.test.ts +++ b/tests/api/admin-notifications-test.routes.test.ts @@ -4,9 +4,15 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; let authRequest: any; +const prismaMock = createPrismaMock(); +prismaMock.notificationBackend = { + findUnique: vi.fn(), +} as any; + const requireAuthMock = vi.hoisted(() => vi.fn()); const requireAdminMock = vi.hoisted(() => vi.fn()); const notificationServiceMock = vi.hoisted(() => ({ @@ -15,6 +21,10 @@ const notificationServiceMock = vi.hoisted(() => ({ sendToBackend: vi.fn(), })); +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + vi.mock('@/lib/middleware/auth', () => ({ requireAuth: requireAuthMock, requireAdmin: requireAdminMock, @@ -126,5 +136,62 @@ describe('Admin notifications test route', () => { expect(payload.success).toBe(true); expect(notificationServiceMock.encryptConfig).toHaveBeenCalledWith('pushover', testConfig.config); }); + + it('tests existing backend using backend ID', async () => { + const existingBackend = { + id: 'backend-1', + type: 'discord', + name: 'Discord - Admins', + config: { webhookUrl: 'iv:authTag:encryptedData', username: 'Bot' }, // Already encrypted + events: ['request_available'], + enabled: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + prismaMock.notificationBackend.findUnique.mockResolvedValue(existingBackend); + authRequest.json.mockResolvedValue({ backendId: 'backend-1' }); + notificationServiceMock.sendToBackend.mockResolvedValue(undefined); + + const { POST } = await import('@/app/api/admin/notifications/test/route'); + const response = await POST({ json: authRequest.json } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(prismaMock.notificationBackend.findUnique).toHaveBeenCalledWith({ + where: { id: 'backend-1' }, + }); + // Should use stored config directly, not encrypt again + expect(notificationServiceMock.encryptConfig).not.toHaveBeenCalled(); + expect(notificationServiceMock.sendToBackend).toHaveBeenCalledWith( + 'discord', + existingBackend.config, + expect.any(Object) + ); + }); + + it('returns 404 if backend ID not found', async () => { + prismaMock.notificationBackend.findUnique.mockResolvedValue(null); + authRequest.json.mockResolvedValue({ backendId: 'nonexistent' }); + + const { POST } = await import('@/app/api/admin/notifications/test/route'); + const response = await POST({ json: authRequest.json } as any); + + expect(response.status).toBe(404); + const payload = await response.json(); + expect(payload.error).toBe('NotFound'); + }); + + it('returns validation error when payload is invalid', async () => { + authRequest.json.mockResolvedValue('bad-payload'); + + const { POST } = await import('@/app/api/admin/notifications/test/route'); + const response = await POST({ json: authRequest.json } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + expect(payload.details).toBeDefined(); + }); }); }); diff --git a/tests/api/admin-requests.routes.test.ts b/tests/api/admin-requests.routes.test.ts index 13fef32..d8c5614 100644 --- a/tests/api/admin-requests.routes.test.ts +++ b/tests/api/admin-requests.routes.test.ts @@ -12,6 +12,11 @@ const prismaMock = createPrismaMock(); const requireAuthMock = vi.hoisted(() => vi.fn()); const requireAdminMock = vi.hoisted(() => vi.fn()); const deleteRequestMock = vi.hoisted(() => vi.fn()); +const jobQueueMock = vi.hoisted(() => ({ + addDownloadJob: vi.fn(), + addSearchJob: vi.fn(), + addNotificationJob: vi.fn().mockResolvedValue(undefined), +})); vi.mock('@/lib/db', () => ({ prisma: prismaMock, @@ -26,12 +31,17 @@ vi.mock('@/lib/services/request-delete.service', () => ({ deleteRequest: deleteRequestMock, })); +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + describe('Admin requests routes', () => { beforeEach(() => { vi.clearAllMocks(); authRequest = { user: { id: 'admin-1', role: 'admin' } }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); requireAdminMock.mockImplementation((_req: any, handler: any) => handler()); + jobQueueMock.addNotificationJob.mockResolvedValue(undefined); }); it('returns recent requests', async () => { @@ -73,6 +83,202 @@ describe('Admin requests routes', () => { expect(payload.success).toBe(true); expect(deleteRequestMock).toHaveBeenCalledWith('req-1', 'admin-1'); }); + + it('returns 401 when admin user is missing', async () => { + authRequest.user = null; + + const { DELETE } = await import('@/app/api/admin/requests/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-2' }) }); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('returns 404 when delete service reports missing request', async () => { + deleteRequestMock.mockResolvedValueOnce({ + success: false, + error: 'NotFound', + message: 'Missing', + }); + + const { DELETE } = await import('@/app/api/admin/requests/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-3' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('NotFound'); + }); + + it('returns 500 when delete service fails', async () => { + deleteRequestMock.mockResolvedValueOnce({ + success: false, + error: 'DeleteFailed', + message: 'boom', + }); + + const { DELETE } = await import('@/app/api/admin/requests/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'req-4' }) }); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('DeleteFailed'); + }); + + it('returns pending approval requests', async () => { + prismaMock.request.findMany.mockResolvedValueOnce([{ id: 'req-10', status: 'awaiting_approval' }]); + + const { GET } = await import('@/app/api/admin/requests/pending-approval/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.count).toBe(1); + }); + + it('returns 500 when pending approval fetch fails', async () => { + prismaMock.request.findMany.mockRejectedValueOnce(new Error('fetch failed')); + + const { GET } = await import('@/app/api/admin/requests/pending-approval/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('FetchError'); + }); + + it('returns 401 when approving without a user', async () => { + authRequest.user = null; + const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) }; + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(request as any, { params: Promise.resolve({ id: 'req-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('returns validation error for invalid approval action', async () => { + const request = { json: vi.fn().mockResolvedValue({ action: 'maybe' }) }; + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(request as any, { params: Promise.resolve({ id: 'req-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 404 when approving a missing request', async () => { + prismaMock.request.findUnique.mockResolvedValueOnce(null); + const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) }; + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(request as any, { params: Promise.resolve({ id: 'missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('NotFound'); + }); + + it('returns 400 when request is not awaiting approval', async () => { + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-2', + status: 'pending', + audiobook: { id: 'ab-1', title: 'Title', author: 'Author' }, + user: { id: 'u1', plexUsername: 'user' }, + }); + const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) }; + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(request as any, { params: Promise.resolve({ id: 'req-2' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('InvalidStatus'); + }); + + it('approves request with a selected torrent and triggers download', async () => { + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-3', + status: 'awaiting_approval', + selectedTorrent: { title: 'Torrent' }, + audiobook: { id: 'ab-3', title: 'Title', author: 'Author' }, + user: { id: 'u3', plexUsername: 'user3' }, + userId: 'u3', + }); + prismaMock.request.update.mockResolvedValueOnce({ + id: 'req-3', + status: 'downloading', + audiobook: { id: 'ab-3', title: 'Title', author: 'Author' }, + user: { id: 'u3', plexUsername: 'user3' }, + userId: 'u3', + }); + const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) }; + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(request as any, { params: Promise.resolve({ id: 'req-3' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(jobQueueMock.addDownloadJob).toHaveBeenCalled(); + expect(jobQueueMock.addNotificationJob).toHaveBeenCalled(); + }); + + it('approves request without a selected torrent and triggers search', async () => { + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-4', + status: 'awaiting_approval', + selectedTorrent: null, + audiobook: { id: 'ab-4', title: 'Title', author: 'Author', audibleAsin: 'ASIN4' }, + user: { id: 'u4', plexUsername: 'user4' }, + userId: 'u4', + }); + prismaMock.request.update.mockResolvedValueOnce({ + id: 'req-4', + status: 'pending', + audiobook: { id: 'ab-4', title: 'Title', author: 'Author', audibleAsin: 'ASIN4' }, + user: { id: 'u4', plexUsername: 'user4' }, + userId: 'u4', + }); + const request = { json: vi.fn().mockResolvedValue({ action: 'approve' }) }; + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(request as any, { params: Promise.resolve({ id: 'req-4' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(jobQueueMock.addSearchJob).toHaveBeenCalled(); + expect(jobQueueMock.addNotificationJob).toHaveBeenCalled(); + }); + + it('denies request without triggering jobs', async () => { + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-5', + status: 'awaiting_approval', + selectedTorrent: null, + audiobook: { id: 'ab-5', title: 'Title', author: 'Author' }, + user: { id: 'u5', plexUsername: 'user5' }, + userId: 'u5', + }); + prismaMock.request.update.mockResolvedValueOnce({ + id: 'req-5', + status: 'denied', + audiobook: { id: 'ab-5', title: 'Title', author: 'Author' }, + user: { id: 'u5', plexUsername: 'user5' }, + userId: 'u5', + }); + const request = { json: vi.fn().mockResolvedValue({ action: 'deny' }) }; + + const { POST } = await import('@/app/api/admin/requests/[id]/approve/route'); + const response = await POST(request as any, { params: Promise.resolve({ id: 'req-5' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); + expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled(); + }); }); diff --git a/tests/api/admin-settings-core.routes.test.ts b/tests/api/admin-settings-core.routes.test.ts index 7006521..cfe62eb 100644 --- a/tests/api/admin-settings-core.routes.test.ts +++ b/tests/api/admin-settings-core.routes.test.ts @@ -129,6 +129,100 @@ describe('Admin settings core routes', () => { expect(invalidateQbMock).toHaveBeenCalled(); }); + it('rejects invalid download client types', async () => { + const request = { + json: vi.fn().mockResolvedValue({ + type: 'transmission', + url: 'http://transmission', + }), + }; + + const { PUT } = await import('@/app/api/admin/settings/download-client/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid client type/); + }); + + it('rejects missing qBittorrent credentials', async () => { + const request = { + json: vi.fn().mockResolvedValue({ + type: 'qbittorrent', + url: 'http://qbt', + password: 'pass', + remotePathMappingEnabled: false, + }), + }; + + const { PUT } = await import('@/app/api/admin/settings/download-client/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/URL, username, and password/); + }); + + it('rejects missing SABnzbd credentials', async () => { + const request = { + json: vi.fn().mockResolvedValue({ + type: 'sabnzbd', + url: 'http://sab', + remotePathMappingEnabled: false, + }), + }; + + const { PUT } = await import('@/app/api/admin/settings/download-client/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/API key/); + }); + + it('rejects path mapping when required fields are missing', async () => { + const request = { + json: vi.fn().mockResolvedValue({ + type: 'qbittorrent', + url: 'http://qbt', + username: 'user', + password: 'pass', + remotePathMappingEnabled: true, + }), + }; + + const { PUT } = await import('@/app/api/admin/settings/download-client/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Remote path and local path/); + }); + + it('rejects invalid path mapping configuration', async () => { + pathMapperMock.validate.mockImplementationOnce(() => { + throw new Error('bad mapping'); + }); + const request = { + json: vi.fn().mockResolvedValue({ + type: 'qbittorrent', + url: 'http://qbt', + username: 'user', + password: 'pass', + remotePathMappingEnabled: true, + remotePath: '/remote', + localPath: '/local', + }), + }; + + const { PUT } = await import('@/app/api/admin/settings/download-client/route'); + const response = await PUT(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/bad mapping/); + }); + it('updates paths settings', async () => { const request = { json: vi.fn().mockResolvedValue({ diff --git a/tests/api/admin-settings-tests.routes.test.ts b/tests/api/admin-settings-tests.routes.test.ts index ea9d448..20f4547 100644 --- a/tests/api/admin-settings-tests.routes.test.ts +++ b/tests/api/admin-settings-tests.routes.test.ts @@ -24,6 +24,7 @@ const qbtMock = vi.hoisted(() => ({ const sabnzbdMock = vi.hoisted(() => ({ testConnection: vi.fn(), })); +const maskedValue = '\u2022\u2022\u2022\u2022'; const testFlareSolverrMock = vi.hoisted(() => vi.fn()); const fsMock = vi.hoisted(() => ({ access: vi.fn(), @@ -94,6 +95,40 @@ describe('Admin settings test routes', () => { expect(payload.success).toBe(true); }); + it('rejects Plex test when URL or token is missing', async () => { + const request = { json: vi.fn().mockResolvedValue({ url: '', token: 'token' }) }; + const { POST } = await import('@/app/api/admin/settings/test-plex/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/URL and token are required/); + }); + + it('rejects Plex test when masked token is missing in storage', async () => { + prismaMock.configuration.findUnique.mockResolvedValueOnce(null); + const request = { json: vi.fn().mockResolvedValue({ url: 'http://plex', token: maskedValue }) }; + + const { POST } = await import('@/app/api/admin/settings/test-plex/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/No stored token/); + }); + + it('returns error when Plex connection test fails', async () => { + plexServiceMock.testConnection.mockResolvedValueOnce({ success: false, message: 'bad token' }); + const request = { json: vi.fn().mockResolvedValue({ url: 'http://plex', token: 'token' }) }; + + const { POST } = await import('@/app/api/admin/settings/test-plex/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/bad token/); + }); + it('tests Prowlarr connection', async () => { prowlarrMock.getIndexers.mockResolvedValueOnce([{ id: 1, name: 'Indexer', protocol: 'torrent', enable: true }]); const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) }; @@ -105,6 +140,40 @@ describe('Admin settings test routes', () => { expect(payload.success).toBe(true); }); + it('rejects Prowlarr test when URL or API key is missing', async () => { + const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr' }) }; + const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/URL and API key are required/); + }); + + it('rejects masked Prowlarr API key when no stored key exists', async () => { + prismaMock.configuration.findUnique.mockResolvedValueOnce(null); + const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: maskedValue }) }; + + const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/No stored API key/); + }); + + it('returns error when Prowlarr test fails', async () => { + prowlarrMock.getIndexers.mockRejectedValueOnce(new Error('prowlarr down')); + const request = { json: vi.fn().mockResolvedValue({ url: 'http://prowlarr', apiKey: 'key' }) }; + + const { POST } = await import('@/app/api/admin/settings/test-prowlarr/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/prowlarr down/); + }); + it('tests download client connection', async () => { qbtMock.testConnectionWithCredentials.mockResolvedValueOnce('4.0.0'); const request = { @@ -252,6 +321,40 @@ describe('Admin settings test routes', () => { expect(payload.success).toBe(true); }); + + it('rejects FlareSolverr test when URL is missing', async () => { + const request = { json: vi.fn().mockResolvedValue({}) }; + + const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/URL is required/); + }); + + it('rejects FlareSolverr test when URL has invalid scheme', async () => { + const request = { json: vi.fn().mockResolvedValue({ url: 'ftp://flare' }) }; + + const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/must start with http/); + }); + + it('returns error when FlareSolverr test throws', async () => { + testFlareSolverrMock.mockRejectedValueOnce(new Error('flare down')); + const request = { json: vi.fn().mockResolvedValue({ url: 'http://flare' }) }; + + const { POST } = await import('@/app/api/admin/settings/ebook/test-flaresolverr/route'); + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.message).toMatch(/flare down/); + }); }); diff --git a/tests/api/admin-users.routes.test.ts b/tests/api/admin-users.routes.test.ts index a884044..67e0da4 100644 --- a/tests/api/admin-users.routes.test.ts +++ b/tests/api/admin-users.routes.test.ts @@ -67,6 +67,105 @@ describe('Admin users routes', () => { expect(payload.user.role).toBe('admin'); }); + it('rejects invalid roles', async () => { + const request = { json: vi.fn().mockResolvedValue({ role: 'owner' }) }; + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(request as any, { params: Promise.resolve({ id: 'u3' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid role/); + }); + + it('rejects invalid autoApproveRequests values', async () => { + const request = { json: vi.fn().mockResolvedValue({ role: 'user', autoApproveRequests: 'yes' }) }; + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(request as any, { params: Promise.resolve({ id: 'u3' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/autoApproveRequests/); + }); + + it('prevents changing your own role', async () => { + const request = { json: vi.fn().mockResolvedValue({ role: 'user' }) }; + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(request as any, { params: Promise.resolve({ id: 'admin-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/cannot change your own role/i); + }); + + it('returns 404 when updating a missing user', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null); + const request = { json: vi.fn().mockResolvedValue({ role: 'admin' }) }; + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(request as any, { params: Promise.resolve({ id: 'missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/User not found/); + }); + + it('prevents modifying deleted users', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + isSetupAdmin: false, + authProvider: 'local', + plexUsername: 'user', + deletedAt: new Date(), + role: 'user', + }); + const request = { json: vi.fn().mockResolvedValue({ role: 'admin' }) }; + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(request as any, { params: Promise.resolve({ id: 'u3' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/deleted user/); + }); + + it('prevents changing setup admin role', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + isSetupAdmin: true, + authProvider: 'local', + plexUsername: 'setup-admin', + deletedAt: null, + role: 'admin', + }); + const request = { json: vi.fn().mockResolvedValue({ role: 'user' }) }; + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(request as any, { params: Promise.resolve({ id: 'setup-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/setup admin role/); + }); + + it('rejects admin role with autoApproveRequests false', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + isSetupAdmin: false, + authProvider: 'local', + plexUsername: 'admin', + deletedAt: null, + role: 'admin', + }); + const request = { json: vi.fn().mockResolvedValue({ role: 'admin', autoApproveRequests: false }) }; + + const { PUT } = await import('@/app/api/admin/users/[id]/route'); + const response = await PUT(request as any, { params: Promise.resolve({ id: 'admin-2' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/auto-approve requests/); + }); + it('allows autoApproveRequests update for OIDC users without role change', async () => { prismaMock.user.findUnique.mockResolvedValueOnce({ isSetupAdmin: false, @@ -151,6 +250,80 @@ describe('Admin users routes', () => { expect(payload.success).toBe(true); }); + it('prevents deleting yourself', async () => { + const { DELETE } = await import('@/app/api/admin/users/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'admin-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/delete your own account/); + }); + + it('returns 404 when deleting a missing user', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + const { DELETE } = await import('@/app/api/admin/users/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/User not found/); + }); + + it('returns 400 when deleting an already deleted user', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'u4', + plexUsername: 'user', + isSetupAdmin: false, + authProvider: 'local', + deletedAt: new Date(), + _count: { requests: 1 }, + }); + + const { DELETE } = await import('@/app/api/admin/users/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'u4' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/already been deleted/); + }); + + it('prevents deleting setup admin users', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'setup-1', + plexUsername: 'setup-admin', + isSetupAdmin: true, + authProvider: 'local', + deletedAt: null, + _count: { requests: 2 }, + }); + + const { DELETE } = await import('@/app/api/admin/users/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'setup-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/setup admin/); + }); + + it('prevents deleting non-local users', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'plex-1', + plexUsername: 'plexuser', + isSetupAdmin: false, + authProvider: 'plex', + deletedAt: null, + _count: { requests: 0 }, + }); + + const { DELETE } = await import('@/app/api/admin/users/[id]/route'); + const response = await DELETE({} as any, { params: Promise.resolve({ id: 'plex-1' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toMatch(/Cannot delete Plex users/); + }); + it('approves a pending user', async () => { prismaMock.user.findUnique.mockResolvedValueOnce({ id: 'u5', @@ -166,6 +339,49 @@ describe('Admin users routes', () => { expect(payload.success).toBe(true); }); + + it('returns 404 when approving a missing user', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null); + const request = { json: vi.fn().mockResolvedValue({ approve: true }) }; + + const { POST } = await import('@/app/api/admin/users/[id]/approve/route'); + const response = await POST(request as any, { params: Promise.resolve({ id: 'missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/not found/i); + }); + + it('returns 400 when user is not pending approval', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'u6', + plexUsername: 'user', + registrationStatus: 'approved', + }); + const request = { json: vi.fn().mockResolvedValue({ approve: true }) }; + + const { POST } = await import('@/app/api/admin/users/[id]/approve/route'); + const response = await POST(request as any, { params: Promise.resolve({ id: 'u6' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/not pending/); + }); + + it('rejects a pending user when approve is false', async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'u7', + plexUsername: 'user', + registrationStatus: 'pending_approval', + }); + prismaMock.user.delete.mockResolvedValueOnce({}); + const request = { json: vi.fn().mockResolvedValue({ approve: false }) }; + + const { POST } = await import('@/app/api/admin/users/[id]/approve/route'); + const response = await POST(request as any, { params: Promise.resolve({ id: 'u7' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.message).toMatch(/rejected/); + }); }); - - diff --git a/tests/api/audiobooks-browse.routes.test.ts b/tests/api/audiobooks-browse.routes.test.ts index 2456610..ff90130 100644 --- a/tests/api/audiobooks-browse.routes.test.ts +++ b/tests/api/audiobooks-browse.routes.test.ts @@ -94,6 +94,16 @@ describe('Audiobooks browse routes', () => { expect(payload.audiobooks[0].coverArtUrl).toBe('/api/cache/thumbnails/asin.jpg'); }); + it('returns 400 for invalid new releases pagination', async () => { + const { GET } = await import('@/app/api/audiobooks/new-releases/route'); + + const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=0') } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + it('returns new release audiobooks', async () => { prismaMock.audibleCache.findMany.mockResolvedValueOnce([]); prismaMock.audibleCache.count.mockResolvedValueOnce(0); @@ -106,6 +116,54 @@ describe('Audiobooks browse routes', () => { expect(payload.count).toBe(0); }); + it('enriches new releases and uses cached cover URLs', async () => { + prismaMock.audibleCache.findMany.mockResolvedValueOnce([ + { + asin: 'ASIN', + title: 'Title', + author: 'Author', + narrator: null, + description: null, + coverArtUrl: 'http://image', + cachedCoverPath: '/tmp/cache/asin.jpg', + durationMinutes: 90, + releaseDate: new Date('2024-01-01'), + rating: '4.2', + genres: ['Fiction'], + lastSyncedAt: new Date('2024-01-02'), + }, + ]); + prismaMock.audibleCache.count.mockResolvedValueOnce(1); + currentUserMock.mockReturnValue({ sub: 'user-1' }); + enrichMock.mockResolvedValueOnce([{ asin: 'ASIN', available: true }]); + + const { GET } = await import('@/app/api/audiobooks/new-releases/route'); + const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(enrichMock).toHaveBeenCalledWith( + [ + expect.objectContaining({ + asin: 'ASIN', + coverArtUrl: '/api/cache/thumbnails/asin.jpg', + }), + ], + 'user-1' + ); + }); + + it('returns 500 when new releases query fails', async () => { + prismaMock.audibleCache.findMany.mockRejectedValueOnce(new Error('db down')); + + const { GET } = await import('@/app/api/audiobooks/new-releases/route'); + const response = await GET({ nextUrl: new URL('http://app/api/audiobooks/new-releases?page=1&limit=1') } as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('FetchError'); + }); + it('returns audiobook details when ASIN is valid', async () => { audibleServiceMock.getAudiobookDetails.mockResolvedValue({ asin: 'ASIN123456', title: 'Title' }); const { GET } = await import('@/app/api/audiobooks/[asin]/route'); @@ -117,6 +175,38 @@ describe('Audiobooks browse routes', () => { expect(payload.audiobook.asin).toBe('ASIN123456'); }); + it('returns 400 when ASIN is invalid', async () => { + const { GET } = await import('@/app/api/audiobooks/[asin]/route'); + + const response = await GET({} as any, { params: Promise.resolve({ asin: 'BAD' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 404 when audiobook is not found', async () => { + audibleServiceMock.getAudiobookDetails.mockResolvedValue(null); + const { GET } = await import('@/app/api/audiobooks/[asin]/route'); + + const response = await GET({} as any, { params: Promise.resolve({ asin: 'ASIN123456' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('NotFound'); + }); + + it('returns 500 when audiobook lookup fails', async () => { + audibleServiceMock.getAudiobookDetails.mockRejectedValue(new Error('fail')); + const { GET } = await import('@/app/api/audiobooks/[asin]/route'); + + const response = await GET({} as any, { params: Promise.resolve({ asin: 'ASIN123456' }) }); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('FetchError'); + }); + it('returns cached covers for login', async () => { prismaMock.audibleCache.findMany.mockResolvedValueOnce([ { asin: 'ASIN', title: 'Title', author: 'Author', cachedCoverPath: '/tmp/asin.jpg', coverArtUrl: null }, diff --git a/tests/api/audiobooks-request-torrent.routes.test.ts b/tests/api/audiobooks-request-torrent.routes.test.ts index 7dc089a..02fc03f 100644 --- a/tests/api/audiobooks-request-torrent.routes.test.ts +++ b/tests/api/audiobooks-request-torrent.routes.test.ts @@ -15,6 +15,9 @@ const jobQueueMock = vi.hoisted(() => ({ addNotificationJob: vi.fn(() => Promise.resolve()), })); const findPlexMatchMock = vi.hoisted(() => vi.fn()); +const audibleServiceMock = vi.hoisted(() => ({ + getAudiobookDetails: vi.fn(), +})); vi.mock('@/lib/middleware/auth', () => ({ requireAuth: requireAuthMock, @@ -33,9 +36,7 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({ })); vi.mock('@/lib/integrations/audible.service', () => ({ - getAudibleService: () => ({ - getAudiobookDetails: vi.fn().mockResolvedValue(null), - }), + getAudibleService: () => audibleServiceMock, })); describe('Request with torrent route', () => { @@ -46,6 +47,7 @@ describe('Request with torrent route', () => { json: vi.fn(), }; requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + audibleServiceMock.getAudiobookDetails.mockResolvedValue(null); }); it('returns 409 when audiobook is already being processed', async () => { @@ -68,6 +70,216 @@ describe('Request with torrent route', () => { expect(payload.error).toBe('BeingProcessed'); }); + it('returns 401 when user is missing', async () => { + authRequest.user = null; + + const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('returns 409 when audiobook is already available', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' }, + torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce({ + id: 'req-1', + status: 'available', + userId: 'user-2', + user: { plexUsername: 'other' }, + } as any); + + const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(409); + expect(payload.error).toBe('AlreadyAvailable'); + }); + + it('returns 409 when Plex match already exists', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' }, + torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + findPlexMatchMock.mockResolvedValueOnce({ plexGuid: 'plex://item' }); + + const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(409); + expect(payload.error).toBe('AlreadyAvailable'); + expect(payload.plexGuid).toBe('plex://item'); + }); + + it('returns 409 for duplicate active requests', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' }, + torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + findPlexMatchMock.mockResolvedValueOnce(null); + prismaMock.audiobook.findFirst.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author' } as any); + prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-2', status: 'pending' } as any); + + const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(409); + expect(payload.error).toBe('DuplicateRequest'); + }); + + it('deletes failed requests before creating a new one', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' }, + torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + findPlexMatchMock.mockResolvedValueOnce(null); + prismaMock.audiobook.findFirst.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author' } as any); + prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-old', status: 'failed' } as any); + prismaMock.request.delete.mockResolvedValueOnce({} as any); + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'user-1', + role: 'user', + autoApproveRequests: true, + plexUsername: 'user', + } as any); + prismaMock.request.create.mockResolvedValueOnce({ + id: 'req-3', + audiobook: { id: 'ab-1', title: 'Title', author: 'Author' }, + user: { id: 'user-1', plexUsername: 'user' }, + } as any); + + const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(prismaMock.request.delete).toHaveBeenCalledWith({ where: { id: 'req-old' } }); + }); + + it('returns 404 when user lookup fails', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' }, + torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + findPlexMatchMock.mockResolvedValueOnce(null); + prismaMock.audiobook.findFirst.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author' } as any); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('UserNotFound'); + }); + + it('stores selected torrent when approval is required', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' }, + torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + findPlexMatchMock.mockResolvedValueOnce(null); + prismaMock.audiobook.findFirst.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author' } as any); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'user-1', + role: 'user', + autoApproveRequests: false, + plexUsername: 'user', + } as any); + prismaMock.request.create.mockResolvedValueOnce({ + id: 'req-4', + audiobook: { title: 'Title', author: 'Author' }, + user: { id: 'user-1', plexUsername: 'user' }, + } as any); + + const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(prismaMock.request.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: 'awaiting_approval', + selectedTorrent: expect.objectContaining({ guid: 'guid' }), + }), + }) + ); + expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled(); + expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( + 'request_pending_approval', + 'req-4', + 'Title', + 'Author', + 'user' + ); + }); + + it('updates year from Audnexus when audiobook already exists', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' }, + torrent: { guid: 'guid', title: 'Torrent', size: 100, indexer: 'Indexer', downloadUrl: 'url', publishDate: '2024-01-01' }, + }); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + findPlexMatchMock.mockResolvedValueOnce(null); + audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({ releaseDate: '2020-01-02' }); + prismaMock.audiobook.findFirst.mockResolvedValueOnce({ id: 'ab-2', title: 'Title', author: 'Author' } as any); + prismaMock.audiobook.update.mockResolvedValueOnce({ id: 'ab-2', year: 2020 } as any); + prismaMock.request.findFirst.mockResolvedValueOnce(null); + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'user-1', + role: 'admin', + autoApproveRequests: null, + plexUsername: 'user', + } as any); + prismaMock.request.create.mockResolvedValueOnce({ + id: 'req-5', + audiobook: { id: 'ab-2', title: 'Title', author: 'Author' }, + user: { id: 'user-1', plexUsername: 'user' }, + } as any); + + const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(201); + expect(payload.success).toBe(true); + expect(prismaMock.audiobook.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ year: 2020 }), + }) + ); + }); + + it('returns validation errors for invalid payloads', async () => { + authRequest.json.mockResolvedValue({ + audiobook: { title: 'Missing fields' }, + }); + + const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + it('creates request and queues download job', async () => { authRequest.json.mockResolvedValue({ audiobook: { asin: 'ASIN', title: 'Title', author: 'Author' }, @@ -104,4 +316,3 @@ describe('Request with torrent route', () => { }); }); - diff --git a/tests/api/auth-misc.routes.test.ts b/tests/api/auth-misc.routes.test.ts index e7cbc8b..bd96443 100644 --- a/tests/api/auth-misc.routes.test.ts +++ b/tests/api/auth-misc.routes.test.ts @@ -95,6 +95,50 @@ describe('Auth misc routes', () => { expect(payload.accessToken).toBe('access-token'); }); + it('returns 400 when refresh token is missing', async () => { + const { POST } = await import('@/app/api/auth/refresh/route'); + const response = await POST({ json: vi.fn().mockResolvedValue({}) } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 401 when refresh token is invalid', async () => { + verifyRefreshTokenMock.mockReturnValue(null); + const { POST } = await import('@/app/api/auth/refresh/route'); + const response = await POST({ json: vi.fn().mockResolvedValue({ refreshToken: 'bad' }) } as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('returns 401 when user is not found for refresh token', async () => { + verifyRefreshTokenMock.mockReturnValue({ sub: 'user-missing' }); + prismaMock.user.findUnique.mockResolvedValue(null); + + const { POST } = await import('@/app/api/auth/refresh/route'); + const response = await POST({ json: vi.fn().mockResolvedValue({ refreshToken: 'refresh' }) } as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('returns 500 when refresh token verification throws', async () => { + verifyRefreshTokenMock.mockImplementation(() => { + throw new Error('bad token'); + }); + + const { POST } = await import('@/app/api/auth/refresh/route'); + const response = await POST({ json: vi.fn().mockResolvedValue({ refreshToken: 'refresh' }) } as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('RefreshError'); + }); + it('returns provider info for audiobookshelf mode', async () => { configServiceMock.get .mockResolvedValueOnce('audiobookshelf') diff --git a/tests/api/auth-oidc.routes.test.ts b/tests/api/auth-oidc.routes.test.ts index 9609eac..135c12b 100644 --- a/tests/api/auth-oidc.routes.test.ts +++ b/tests/api/auth-oidc.routes.test.ts @@ -38,6 +38,27 @@ describe('OIDC auth routes', () => { expect(response.headers.get('location')).toBe('http://oidc/login'); }); + it('returns error when OIDC login URL is missing', async () => { + authProviderMock.initiateLogin.mockResolvedValue({}); + const { GET } = await import('@/app/api/auth/oidc/login/route'); + + const response = await GET(); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/Failed to generate authorization URL/); + }); + + it('redirects to login when OIDC login initiation fails', async () => { + authProviderMock.initiateLogin.mockRejectedValue(new Error('boom')); + const { GET } = await import('@/app/api/auth/oidc/login/route'); + + const response = await GET(); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toContain('/login?error='); + }); + it('redirects to login on missing code/state', async () => { const { GET } = await import('@/app/api/auth/oidc/callback/route'); diff --git a/tests/api/auth-plex.routes.test.ts b/tests/api/auth-plex.routes.test.ts index 54bb700..d440d39 100644 --- a/tests/api/auth-plex.routes.test.ts +++ b/tests/api/auth-plex.routes.test.ts @@ -278,6 +278,64 @@ describe('Plex auth routes', () => { expect(payload.users).toHaveLength(1); }); + it('rejects Plex home users when token is missing', async () => { + const { GET } = await import('@/app/api/auth/plex/home-users/route'); + const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users') as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('returns 500 when Plex home users fetch fails', async () => { + plexServiceMock.getHomeUsers.mockRejectedValue(new Error('boom')); + + const { GET } = await import('@/app/api/auth/plex/home-users/route'); + const response = await GET(makeRequest('http://localhost/api/auth/plex/home-users', { 'x-plex-token': 'token' }) as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('ServerError'); + }); + + it('rejects profile switch without main account token', async () => { + const { POST } = await import('@/app/api/auth/plex/switch-profile/route'); + const request = makeRequest('http://localhost/api/auth/plex/switch-profile'); + request.json.mockResolvedValue({ userId: 'home-1' }); + + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('rejects profile switch when userId is missing', async () => { + const { POST } = await import('@/app/api/auth/plex/switch-profile/route'); + const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' }); + request.json.mockResolvedValue({}); + + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 401 for invalid profile PIN', async () => { + plexServiceMock.switchHomeUser.mockRejectedValue(new Error('Invalid PIN')); + + const { POST } = await import('@/app/api/auth/plex/switch-profile/route'); + const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' }); + request.json.mockResolvedValue({ userId: 'home-1', pin: '0000' }); + + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('InvalidPIN'); + }); + it('switches Plex profile using provided profile info', async () => { plexServiceMock.switchHomeUser.mockResolvedValue('profile-token'); prismaMock.user.count.mockResolvedValue(1); @@ -304,6 +362,51 @@ describe('Plex auth routes', () => { expect(payload.success).toBe(true); expect(payload.accessToken).toBe('access-token'); }); + + it('switches Plex profile using getUserInfo fallback', async () => { + plexServiceMock.switchHomeUser.mockResolvedValue('profile-token'); + plexServiceMock.getUserInfo.mockResolvedValue({ + id: 'plex-3', + username: 'Fallback', + email: 'user@example.com', + thumb: '/avatar', + }); + prismaMock.user.count.mockResolvedValue(0); + prismaMock.user.upsert.mockResolvedValue({ + id: 'user-3', + plexId: 'plex-3', + plexUsername: 'Fallback', + plexEmail: 'user@example.com', + role: 'admin', + avatarUrl: '/avatar', + }); + + const { POST } = await import('@/app/api/auth/plex/switch-profile/route'); + const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' }); + request.json.mockResolvedValue({ userId: 'home-2', pin: '1234' }); + + const response = await POST(request as any); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.user.plexId).toBe('plex-3'); + expect(payload.user.role).toBe('admin'); + }); + + it('returns 500 when profile info lookup fails', async () => { + plexServiceMock.switchHomeUser.mockResolvedValue('profile-token'); + plexServiceMock.getUserInfo.mockResolvedValue({ id: null }); + + const { POST } = await import('@/app/api/auth/plex/switch-profile/route'); + const request = makeRequest('http://localhost/api/auth/plex/switch-profile', { 'x-plex-token': 'main-token' }); + request.json.mockResolvedValue({ userId: 'home-2', pin: '1234' }); + + const response = await POST(request as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toBe('ServerError'); + }); }); diff --git a/tests/api/bookdate-library.routes.test.ts b/tests/api/bookdate-library.routes.test.ts new file mode 100644 index 0000000..a14ec39 --- /dev/null +++ b/tests/api/bookdate-library.routes.test.ts @@ -0,0 +1,134 @@ +/** + * Component: BookDate Library Route Tests + * Documentation: documentation/features/bookdate.md + */ + +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 configMock = vi.hoisted(() => ({ + getBackendMode: vi.fn(), + get: vi.fn(), + getPlexConfig: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +vi.mock('@/lib/middleware/auth', () => ({ + requireAuth: requireAuthMock, +})); + +vi.mock('@/lib/services/config.service', () => ({ + getConfigService: () => configMock, +})); + +describe('BookDate library route', () => { + beforeEach(() => { + vi.clearAllMocks(); + authRequest = { user: { id: 'user-1' } }; + requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest)); + }); + + it('returns 400 when Audiobookshelf library ID is missing', async () => { + configMock.getBackendMode.mockResolvedValue('audiobookshelf'); + configMock.get.mockResolvedValue(null); + + const { GET } = await import('@/app/api/bookdate/library/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Audiobookshelf library ID/i); + }); + + it('returns 400 when Plex library ID is missing', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ libraryId: null }); + + const { GET } = await import('@/app/api/bookdate/library/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Plex library ID/i); + }); + + it('returns books with cover priority (library cache > audible cache > null)', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ libraryId: 'lib-1' }); + + prismaMock.plexLibrary.findMany.mockResolvedValue([ + { + id: 'book-1', + title: 'Cached Cover', + author: 'Author A', + asin: 'ASIN1', + cachedLibraryCoverPath: '/cache/library/cover1.jpg', + }, + { + id: 'book-2', + title: 'Audible Cover', + author: 'Author B', + asin: 'ASIN2', + cachedLibraryCoverPath: null, + }, + { + id: 'book-3', + title: 'No Cover', + author: 'Author C', + asin: null, + cachedLibraryCoverPath: null, + }, + ]); + + prismaMock.audibleCache.findMany.mockResolvedValue([ + { asin: 'ASIN1', coverArtUrl: 'http://audible/cover1.jpg' }, + { asin: 'ASIN2', coverArtUrl: 'http://audible/cover2.jpg' }, + ]); + + const { GET } = await import('@/app/api/bookdate/library/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload.books).toEqual([ + { + id: 'book-1', + title: 'Cached Cover', + author: 'Author A', + coverUrl: '/api/cache/library/cover1.jpg', + }, + { + id: 'book-2', + title: 'Audible Cover', + author: 'Author B', + coverUrl: 'http://audible/cover2.jpg', + }, + { + id: 'book-3', + title: 'No Cover', + author: 'Author C', + coverUrl: null, + }, + ]); + }); + + it('returns 500 when database lookup fails', async () => { + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.getPlexConfig.mockResolvedValue({ libraryId: 'lib-1' }); + prismaMock.plexLibrary.findMany.mockRejectedValue(new Error('db down')); + + const { GET } = await import('@/app/api/bookdate/library/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/db down/i); + }); +}); diff --git a/tests/api/bookdate.routes.test.ts b/tests/api/bookdate.routes.test.ts index 2da4f48..1786998 100644 --- a/tests/api/bookdate.routes.test.ts +++ b/tests/api/bookdate.routes.test.ts @@ -355,6 +355,61 @@ describe('BookDate routes', () => { expect(payload.recommendations).toHaveLength(1); }); + it('returns 500 when AI response is missing recommendations', async () => { + prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]); + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ + isVerified: true, + isEnabled: true, + provider: 'openai', + model: 'gpt', + apiKey: 'enc-key', + baseUrl: null, + }); + prismaMock.user.findUnique.mockResolvedValueOnce({ + bookDateLibraryScope: 'full', + bookDateCustomPrompt: null, + }); + bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}'); + bookdateHelpersMock.callAI.mockResolvedValueOnce({ invalid: true }); + + const { GET } = await import('@/app/api/bookdate/recommendations/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/Invalid AI response format/); + }); + + it('returns generated response even when no valid recommendations match', async () => { + prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]); + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ + isVerified: true, + isEnabled: true, + provider: 'openai', + model: 'gpt', + apiKey: 'enc-key', + baseUrl: null, + }); + prismaMock.user.findUnique.mockResolvedValueOnce({ + bookDateLibraryScope: 'full', + bookDateCustomPrompt: null, + }); + bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}'); + bookdateHelpersMock.callAI.mockResolvedValueOnce({ + recommendations: [{ title: 'Title only' }, { author: 'Author only' }], + }); + prismaMock.bookDateRecommendation.findMany.mockResolvedValueOnce([]); + (prismaMock.bookDateRecommendation as any).createMany = vi.fn(); + + const { GET } = await import('@/app/api/bookdate/recommendations/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.source).toBe('generated'); + expect(payload.generatedCount).toBe(0); + expect((prismaMock.bookDateRecommendation as any).createMany).not.toHaveBeenCalled(); + }); + it('returns error when generating recommendations without config', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce(null); @@ -395,6 +450,49 @@ describe('BookDate routes', () => { expect(payload.error).toMatch(/Could not find any new recommendations/i); }); + it('returns 404 when user is missing during generation', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ + isVerified: true, + isEnabled: true, + provider: 'openai', + model: 'gpt', + apiKey: 'enc-key', + baseUrl: null, + }); + prismaMock.user.findUnique.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/bookdate/generate/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/User not found/); + }); + + it('returns 500 when generate receives invalid AI response', async () => { + prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ + isVerified: true, + isEnabled: true, + provider: 'openai', + model: 'gpt', + apiKey: 'enc-key', + baseUrl: null, + }); + prismaMock.user.findUnique.mockResolvedValueOnce({ + bookDateLibraryScope: 'full', + bookDateCustomPrompt: null, + }); + bookdateHelpersMock.buildAIPrompt.mockResolvedValueOnce('{}'); + bookdateHelpersMock.callAI.mockResolvedValueOnce({ invalid: true }); + + const { POST } = await import('@/app/api/bookdate/generate/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/Invalid AI response format/); + }); + it('stores generated recommendations from the AI', async () => { prismaMock.bookDateConfig.findFirst.mockResolvedValueOnce({ isVerified: true, @@ -436,6 +534,112 @@ describe('BookDate routes', () => { expect(payload.recommendations).toHaveLength(1); }); + it('returns 400 when swipe payload is missing fields', async () => { + authRequest.json.mockResolvedValue({ action: 'left' }); + + const { POST } = await import('@/app/api/bookdate/swipe/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/recommendationId and action/); + }); + + it('returns 400 when swipe action is invalid', async () => { + authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'sideways' }); + + const { POST } = await import('@/app/api/bookdate/swipe/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid action/); + }); + + it('returns 404 when recommendation is missing', async () => { + authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'left' }); + prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/bookdate/swipe/route'); + const response = await POST({} as any); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toMatch(/Recommendation not found/); + }); + + it('does not create a request when right swipe is marked as known', async () => { + authRequest.json.mockResolvedValue({ recommendationId: 'rec-known', action: 'right', markedAsKnown: true }); + prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({ + id: 'rec-known', + userId: 'user-1', + title: 'Known Book', + author: 'Known Author', + audnexusAsin: 'ASIN-KNOWN', + } 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).not.toHaveBeenCalled(); + expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); + }); + + it('records left swipe without creating a request', async () => { + authRequest.json.mockResolvedValue({ recommendationId: 'rec-left', action: 'left' }); + prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({ + id: 'rec-left', + userId: 'user-1', + title: 'Left Book', + author: 'Left Author', + audnexusAsin: 'ASIN-LEFT', + } as any); + prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} 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).not.toHaveBeenCalled(); + }); + + it('updates existing audiobook year when Audnexus provides releaseDate', async () => { + authRequest.json.mockResolvedValue({ recommendationId: 'rec-year', action: 'right', markedAsKnown: false }); + prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({ + id: 'rec-year', + userId: 'user-1', + title: 'Year Book', + author: 'Year Author', + audnexusAsin: 'ASIN-YEAR', + } as any); + prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any); + audibleServiceMock.getAudiobookDetails.mockResolvedValueOnce({ + releaseDate: '2020-01-15', + }); + prismaMock.audiobook.findFirst.mockResolvedValueOnce({ + id: 'ab-year', + title: 'Year Book', + author: 'Year Author', + audibleAsin: 'ASIN-YEAR', + } as any); + prismaMock.audiobook.update.mockResolvedValueOnce({ id: 'ab-year' } as any); + prismaMock.request.findFirst.mockResolvedValueOnce({ id: 'req-existing' } 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.audiobook.update).toHaveBeenCalledWith({ + where: { id: 'ab-year' }, + data: { year: 2020 }, + }); + expect(prismaMock.request.create).not.toHaveBeenCalled(); + }); + it('records swipe and creates request on right swipe (admin auto-approves)', async () => { authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'right', markedAsKnown: false }); prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({ @@ -665,4 +869,3 @@ describe('BookDate routes', () => { }); }); - diff --git a/tests/api/config.routes.test.ts b/tests/api/config.routes.test.ts index 6c290a1..3777c8e 100644 --- a/tests/api/config.routes.test.ts +++ b/tests/api/config.routes.test.ts @@ -44,6 +44,40 @@ describe('Config API routes', () => { expect(configServiceMock.setMany).toHaveBeenCalled(); }); + it('returns 400 when configuration update payload is invalid', async () => { + const { PUT } = await import('@/app/api/config/route'); + const response = await PUT({ json: vi.fn().mockResolvedValue({}) } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Validation error/); + }); + + it('returns 500 when configuration update fails', async () => { + configServiceMock.setMany.mockRejectedValueOnce(new Error('db down')); + const { PUT } = await import('@/app/api/config/route'); + const response = await PUT({ + json: vi.fn().mockResolvedValue({ + updates: [{ key: 'plex_url', value: 'http://plex' }], + }), + } as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/Failed to update configuration/); + }); + + it('returns 500 when configuration lookup fails', async () => { + configServiceMock.getAll.mockRejectedValueOnce(new Error('db down')); + const { GET } = await import('@/app/api/config/route'); + + const response = await GET(); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/Failed to get configuration/); + }); + it('returns category configuration', async () => { configServiceMock.getCategory.mockResolvedValue({ plex_url: 'http://plex' }); const { GET } = await import('@/app/api/config/[category]/route'); @@ -54,6 +88,17 @@ describe('Config API routes', () => { expect(payload.category).toBe('plex'); expect(payload.config.plex_url).toBe('http://plex'); }); + + it('returns 500 when category configuration lookup fails', async () => { + configServiceMock.getCategory.mockRejectedValueOnce(new Error('db down')); + const { GET } = await import('@/app/api/config/[category]/route'); + + const response = await GET({} as any, { params: Promise.resolve({ category: 'plex' }) }); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/Failed to get configuration/); + }); }); diff --git a/tests/api/requests-actions.routes.test.ts b/tests/api/requests-actions.routes.test.ts index 49560e0..5616cae 100644 --- a/tests/api/requests-actions.routes.test.ts +++ b/tests/api/requests-actions.routes.test.ts @@ -110,6 +110,60 @@ describe('Request action routes', () => { expect(jobQueueMock.addSearchJob).toHaveBeenCalled(); }); + it('returns 401 when manual search user is not authenticated', async () => { + authRequest.user = null; + + const { POST } = await import('@/app/api/requests/[id]/manual-search/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-auth' }) }); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('returns 404 when manual search request is missing', async () => { + prismaMock.request.findUnique.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/requests/[id]/manual-search/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('NotFound'); + }); + + it('returns 403 when manual search request is not owned', async () => { + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-9', + userId: 'user-2', + status: 'failed', + audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' }, + }); + + const { POST } = await import('@/app/api/requests/[id]/manual-search/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-9' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('returns 400 when manual search status is not eligible', async () => { + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-10', + userId: 'user-1', + status: 'downloading', + audiobook: { id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' }, + }); + + const { POST } = await import('@/app/api/requests/[id]/manual-search/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-10' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + it('selects a torrent and queues download', async () => { authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } }); prismaMock.request.findUnique.mockResolvedValueOnce({ @@ -134,6 +188,134 @@ describe('Request action routes', () => { expect(jobQueueMock.addDownloadJob).toHaveBeenCalled(); }); + it('returns 401 when user is not authenticated', async () => { + authRequest.user = null; + + const { POST } = await import('@/app/api/requests/[id]/select-torrent/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-auth' }) }); + const payload = await response.json(); + + expect(response.status).toBe(401); + expect(payload.error).toBe('Unauthorized'); + }); + + it('returns 400 when torrent data is missing', async () => { + authRequest.json.mockResolvedValue({}); + + const { POST } = await import('@/app/api/requests/[id]/select-torrent/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-missing-torrent' }) }); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toBe('ValidationError'); + }); + + it('returns 404 when request is not found', async () => { + authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } }); + prismaMock.request.findUnique.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/requests/[id]/select-torrent/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-missing' }) }); + const payload = await response.json(); + + expect(response.status).toBe(404); + expect(payload.error).toBe('NotFound'); + }); + + it('returns 403 when user does not own the request', async () => { + authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } }); + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-4', + userId: 'user-2', + status: 'awaiting_search', + audiobook: { id: 'ab-2', title: 'Title', author: 'Author' }, + } as any); + + const { POST } = await import('@/app/api/requests/[id]/select-torrent/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-4' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('Forbidden'); + }); + + it('returns 403 when request is awaiting approval', async () => { + authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } }); + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-5', + userId: 'user-1', + status: 'awaiting_approval', + audiobook: { id: 'ab-2', title: 'Title', author: 'Author' }, + } as any); + + const { POST } = await import('@/app/api/requests/[id]/select-torrent/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-5' }) }); + const payload = await response.json(); + + expect(response.status).toBe(403); + expect(payload.error).toBe('AwaitingApproval'); + }); + + it('stores selected torrent when approval is required by global setting', async () => { + authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } }); + configState.values.set('auto_approve_requests', 'false'); + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-6', + userId: 'user-1', + status: 'awaiting_search', + audiobook: { id: 'ab-3', title: 'Title', author: 'Author' }, + } as any); + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'user-1', + role: 'user', + autoApproveRequests: null, + plexUsername: 'plexuser', + } as any); + prismaMock.request.update.mockResolvedValueOnce({ + id: 'req-6', + status: 'awaiting_approval', + audiobook: { title: 'Title', author: 'Author' }, + } as any); + + const { POST } = await import('@/app/api/requests/[id]/select-torrent/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-6' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.message).toMatch(/approval/i); + expect(jobQueueMock.addDownloadJob).not.toHaveBeenCalled(); + expect(jobQueueMock.addNotificationJob).toHaveBeenCalled(); + }); + + it('auto-approves when global setting is missing and user has no preference', async () => { + authRequest.json.mockResolvedValue({ torrent: { title: 'Torrent', size: 100 } }); + prismaMock.request.findUnique.mockResolvedValueOnce({ + id: 'req-7', + userId: 'user-1', + status: 'awaiting_search', + audiobook: { id: 'ab-4', title: 'Title', author: 'Author' }, + } as any); + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: 'user-1', + role: 'user', + autoApproveRequests: null, + plexUsername: 'plexuser', + } as any); + prismaMock.request.update.mockResolvedValueOnce({ + id: 'req-7', + status: 'downloading', + audiobook: { title: 'Title', author: 'Author' }, + } as any); + + const { POST } = await import('@/app/api/requests/[id]/select-torrent/route'); + const response = await POST({} as any, { params: Promise.resolve({ id: 'req-7' }) }); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(jobQueueMock.addDownloadJob).toHaveBeenCalled(); + expect(jobQueueMock.addNotificationJob).toHaveBeenCalled(); + }); + it('returns error when ebook sidecar is disabled', async () => { configState.values.set('ebook_sidecar_enabled', 'false'); diff --git a/tests/api/setup-status.routes.test.ts b/tests/api/setup-status.routes.test.ts new file mode 100644 index 0000000..aac70c1 --- /dev/null +++ b/tests/api/setup-status.routes.test.ts @@ -0,0 +1,52 @@ +/** + * Component: Setup Status API Route Tests + * Documentation: documentation/testing.md + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPrismaMock } from '../helpers/prisma'; + +const prismaMock = createPrismaMock(); + +vi.mock('@/lib/db', () => ({ + prisma: prismaMock, +})); + +describe('Setup status route', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns true when setup_completed is true', async () => { + prismaMock.configuration.findUnique.mockResolvedValueOnce({ + key: 'setup_completed', + value: 'true', + }); + + const { GET } = await import('@/app/api/setup/status/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.setupComplete).toBe(true); + }); + + it('returns false when setup_completed is missing', async () => { + prismaMock.configuration.findUnique.mockResolvedValueOnce(null); + + const { GET } = await import('@/app/api/setup/status/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.setupComplete).toBe(false); + }); + + it('returns false when the database lookup fails', async () => { + prismaMock.configuration.findUnique.mockRejectedValueOnce(new Error('db not ready')); + + const { GET } = await import('@/app/api/setup/status/route'); + const response = await GET({} as any); + const payload = await response.json(); + + expect(payload.setupComplete).toBe(false); + }); +}); diff --git a/tests/api/setup-tests.routes.test.ts b/tests/api/setup-tests.routes.test.ts index 1dd6dac..fcd6b96 100644 --- a/tests/api/setup-tests.routes.test.ts +++ b/tests/api/setup-tests.routes.test.ts @@ -85,6 +85,55 @@ describe('Setup test routes', () => { expect(payload.libraries[0].id).toBe('1'); }); + it('returns 400 when Plex url or token is missing', async () => { + const { POST } = await import('@/app/api/setup/test-plex/route'); + const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://plex' }) } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/URL and token/); + }); + + it('returns 400 when Plex connection fails', async () => { + plexServiceMock.testConnection.mockResolvedValue({ + success: false, + message: 'bad token', + }); + + const { POST } = await import('@/app/api/setup/test-plex/route'); + const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://plex', token: 'bad' }) } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/bad token/); + }); + + it('returns 400 when Plex info is missing', async () => { + plexServiceMock.testConnection.mockResolvedValue({ + success: true, + info: null, + message: 'missing info', + }); + + const { POST } = await import('@/app/api/setup/test-plex/route'); + const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://plex', token: 'token' }) } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/missing info/); + }); + + it('returns 500 when Plex test throws', async () => { + plexServiceMock.testConnection.mockRejectedValue(new Error('connection error')); + + const { POST } = await import('@/app/api/setup/test-plex/route'); + const response = await POST({ json: vi.fn().mockResolvedValue({ url: 'http://plex', token: 'token' }) } as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/connection error/); + }); + it('tests qBittorrent credentials', async () => { qbtMock.testConnectionWithCredentials.mockResolvedValue('4.0.0'); @@ -103,6 +152,28 @@ describe('Setup test routes', () => { expect(payload.version).toBe('4.0.0'); }); + it('rejects invalid download client type', async () => { + const { POST } = await import('@/app/api/setup/test-download-client/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ type: 'transmission', url: 'http://transmission' }), + } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid client type/); + }); + + it('rejects missing qBittorrent credentials', async () => { + const { POST } = await import('@/app/api/setup/test-download-client/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ type: 'qbittorrent', url: 'http://qbt' }), + } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Username and password/); + }); + it('tests SABnzbd connection', async () => { sabnzbdMock.testConnection.mockResolvedValue({ success: true, version: '3.0' }); @@ -120,6 +191,23 @@ describe('Setup test routes', () => { expect(payload.version).toBe('3.0'); }); + it('returns error when SABnzbd connection fails', async () => { + sabnzbdMock.testConnection.mockResolvedValue({ success: false, error: 'bad key' }); + + const { POST } = await import('@/app/api/setup/test-download-client/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + type: 'sabnzbd', + url: 'http://sab', + password: 'api-key', + }), + } as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/bad key/); + }); + it('tests Prowlarr indexers', async () => { prowlarrMock.getIndexers.mockResolvedValue([ { id: 1, name: 'Indexer', protocol: 'torrent', enable: true, capabilities: {} }, @@ -161,6 +249,72 @@ describe('Setup test routes', () => { expect(payload.issuer.authorizationEndpoint).toBe('http://issuer/auth'); }); + it('returns error when OIDC fields are missing', async () => { + const { POST } = await import('@/app/api/setup/test-oidc/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ issuerUrl: 'http://issuer', clientId: 'client' }), + } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/required/); + }); + + it('returns error when OIDC issuer URL is invalid', async () => { + const { POST } = await import('@/app/api/setup/test-oidc/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + issuerUrl: 'not a url', + clientId: 'client', + clientSecret: 'secret', + }), + } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid issuer URL/); + }); + + it('returns error when OIDC issuer metadata is incomplete', async () => { + issuerMock.discover.mockResolvedValue({ + issuer: 'http://issuer', + metadata: { + token_endpoint: 'http://issuer/token', + userinfo_endpoint: 'http://issuer/user', + }, + }); + + const { POST } = await import('@/app/api/setup/test-oidc/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + issuerUrl: 'http://issuer', + clientId: 'client', + clientSecret: 'secret', + }), + } as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/missing required endpoints/); + }); + + it('returns friendly error when OIDC discovery fails to resolve host', async () => { + issuerMock.discover.mockRejectedValue(new Error('getaddrinfo ENOTFOUND issuer')); + + const { POST } = await import('@/app/api/setup/test-oidc/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ + issuerUrl: 'http://issuer', + clientId: 'client', + clientSecret: 'secret', + }), + } as any); + const payload = await response.json(); + + expect(response.status).toBe(500); + expect(payload.error).toMatch(/Cannot reach OIDC provider/); + }); + it('validates paths are writable', async () => { fsMock.access.mockRejectedValueOnce(new Error('missing')); fsMock.mkdir.mockResolvedValueOnce(undefined); @@ -280,6 +434,63 @@ describe('Setup test routes', () => { expect(payload.success).toBe(true); expect(payload.libraries[0].id).toBe('1'); }); + + it('returns error when Audiobookshelf server URL is missing', async () => { + const { POST } = await import('@/app/api/setup/test-abs/route'); + const response = await POST({ json: vi.fn().mockResolvedValue({}) } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Server URL/); + }); + + it('returns error when saved Audiobookshelf token is missing', async () => { + configServiceMock.get.mockResolvedValueOnce(null); + + const { POST } = await import('@/app/api/setup/test-abs/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ serverUrl: 'http://abs', apiToken: '' }), + } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/API token is required/); + }); + + it('returns error when Audiobookshelf connection fails', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/setup/test-abs/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ serverUrl: 'http://abs', apiToken: 'token' }), + } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Connection failed/); + }); + + it('returns error when Audiobookshelf response is missing libraries', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({}), + }); + vi.stubGlobal('fetch', fetchMock); + + const { POST } = await import('@/app/api/setup/test-abs/route'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ serverUrl: 'http://abs', apiToken: 'token' }), + } as any); + const payload = await response.json(); + + expect(response.status).toBe(400); + expect(payload.error).toMatch(/Invalid response/); + }); }); diff --git a/tests/app/admin-settings.page.test.tsx b/tests/app/admin-settings.page.test.tsx new file mode 100644 index 0000000..db40944 --- /dev/null +++ b/tests/app/admin-settings.page.test.tsx @@ -0,0 +1,164 @@ +/** + * Component: Admin Settings Page Tests + * Documentation: documentation/settings-pages.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import path from 'path'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchWithAuthMock = vi.hoisted(() => vi.fn()); +const saveTabSettingsMock = vi.hoisted(() => vi.fn()); +const getTabValidationMock = vi.hoisted(() => vi.fn()); +const getTabsMock = vi.hoisted(() => vi.fn()); +const parseArrayToCommaSeparatedMock = vi.hoisted(() => vi.fn((value: string) => value)); + +vi.mock('@/lib/utils/api', () => ({ + fetchWithAuth: fetchWithAuthMock, +})); + +const mockAdminSettingsModules = () => { + vi.doMock(path.resolve('src/app/admin/settings/lib/helpers.ts'), () => ({ + parseArrayToCommaSeparated: parseArrayToCommaSeparatedMock, + saveTabSettings: saveTabSettingsMock, + validateAuthSettings: vi.fn(() => ({ valid: true })), + getTabValidation: getTabValidationMock, + getTabs: getTabsMock, + })); + + vi.doMock(path.resolve('src/app/admin/settings/tabs/LibraryTab/LibraryTab.tsx'), () => ({ + LibraryTab: ({ settings, onChange }: { settings: any; onChange: (next: any) => void }) => ( +
+
Library Tab
+ +
+ ), + })); + + vi.doMock(path.resolve('src/app/admin/settings/tabs/AuthTab/AuthTab.tsx'), () => ({ + AuthTab: () =>
Auth Tab
, + })); + + vi.doMock(path.resolve('src/app/admin/settings/tabs/IndexersTab/IndexersTab.tsx'), () => ({ + IndexersTab: () =>
Indexers Tab
, + })); + + vi.doMock(path.resolve('src/app/admin/settings/tabs/DownloadTab/DownloadTab.tsx'), () => ({ + DownloadTab: () =>
Download Tab
, + })); + + vi.doMock(path.resolve('src/app/admin/settings/tabs/PathsTab/PathsTab.tsx'), () => ({ + PathsTab: () =>
Paths Tab
, + })); + + vi.doMock(path.resolve('src/app/admin/settings/tabs/EbookTab/EbookTab.tsx'), () => ({ + EbookTab: () =>
Ebook Tab
, + })); + + vi.doMock(path.resolve('src/app/admin/settings/tabs/BookDateTab/BookDateTab.tsx'), () => ({ + BookDateTab: () =>
BookDate Tab
, + })); + + vi.doMock(path.resolve('src/app/admin/settings/tabs/NotificationsTab/index.tsx'), () => ({ + NotificationsTab: () =>
Notifications Tab
, + })); +}; + +const settingsFixture = { + backendMode: 'plex', + hasLocalUsers: true, + audibleRegion: 'us', + plex: { url: '', token: '', libraryId: '', triggerScanAfterImport: false }, + audiobookshelf: { serverUrl: '', apiToken: '', libraryId: '', triggerScanAfterImport: false }, + oidc: { + enabled: false, + providerName: '', + issuerUrl: '', + clientId: '', + clientSecret: '', + accessControlMethod: 'open', + accessGroupClaim: 'groups', + accessGroupValue: '', + allowedEmails: '[]', + allowedUsernames: '[]', + adminClaimEnabled: false, + adminClaimName: 'groups', + adminClaimValue: '', + }, + registration: { enabled: false, requireAdminApproval: false }, + prowlarr: { url: '', apiKey: '' }, + downloadClient: { + type: 'qbittorrent', + url: '', + username: '', + password: '', + disableSSLVerify: false, + remotePathMappingEnabled: false, + remotePath: '', + localPath: '', + }, + paths: { + downloadDir: '', + mediaDir: '', + audiobookPathTemplate: '', + metadataTaggingEnabled: true, + chapterMergingEnabled: false, + }, + ebook: { enabled: false, preferredFormat: '', baseUrl: '', flaresolverrUrl: '' }, +}; + +describe('AdminSettings', () => { + beforeEach(() => { + fetchWithAuthMock.mockReset(); + saveTabSettingsMock.mockReset(); + getTabValidationMock.mockReset(); + getTabsMock.mockReset(); + parseArrayToCommaSeparatedMock.mockReset(); + vi.resetModules(); + mockAdminSettingsModules(); + }); + + it('fetches settings and renders the settings shell', async () => { + fetchWithAuthMock.mockResolvedValue({ + ok: true, + json: async () => settingsFixture, + }); + getTabValidationMock.mockReturnValue(true); + getTabsMock.mockReturnValue([{ id: 'library', label: 'Library', icon: 'L' }]); + + const { default: AdminSettings } = await import('@/app/admin/settings/page'); + render(); + + expect(await screen.findByText('Settings')).toBeInTheDocument(); + expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/settings'); + }); + + it('saves settings when changes are made and validation passes', async () => { + fetchWithAuthMock.mockResolvedValue({ + ok: true, + json: async () => settingsFixture, + }); + getTabValidationMock.mockReturnValue(true); + getTabsMock.mockReturnValue([{ id: 'library', label: 'Library', icon: 'L' }]); + + const { default: AdminSettings } = await import('@/app/admin/settings/page'); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Change Settings' })); + fireEvent.click(await screen.findByRole('button', { name: 'Save Settings' })); + + await waitFor(() => { + expect(saveTabSettingsMock).toHaveBeenCalledWith( + 'library', + expect.objectContaining({ audibleRegion: 'uk' }), + [], + [] + ); + }); + }); +}); diff --git a/tests/app/admin-users.page.test.tsx b/tests/app/admin-users.page.test.tsx new file mode 100644 index 0000000..ac166a4 --- /dev/null +++ b/tests/app/admin-users.page.test.tsx @@ -0,0 +1,153 @@ +/** + * Component: Admin Users Page Tests + * Documentation: documentation/admin-dashboard.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import AdminUsersPage from '@/app/admin/users/page'; + +const fetchJSONMock = vi.hoisted(() => vi.fn()); +const authenticatedFetcherMock = vi.hoisted(() => vi.fn()); + +const toastMock = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})); + +const swrState = new Map }>(); + +vi.mock('swr', () => ({ + default: (key: string) => { + return swrState.get(key) || { data: undefined, error: undefined, mutate: vi.fn() }; + }, +})); + +vi.mock('@/lib/utils/api', () => ({ + authenticatedFetcher: authenticatedFetcherMock, + fetchJSON: fetchJSONMock, +})); + +vi.mock('@/components/ui/Toast', () => ({ + ToastProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useToast: () => toastMock, +})); + +describe('AdminUsersPage', () => { + beforeEach(() => { + swrState.clear(); + fetchJSONMock.mockReset(); + toastMock.success.mockReset(); + toastMock.error.mockReset(); + }); + + it('toggles global auto-approve and persists setting', async () => { + const mutateUsers = vi.fn(); + const mutatePending = vi.fn(); + const mutateGlobal = vi.fn(); + + swrState.set('/api/admin/users', { + data: { users: [{ id: 'u1', plexUsername: 'User', plexId: 'plex-1', role: 'user', isSetupAdmin: false, authProvider: 'local', plexEmail: null, avatarUrl: null, createdAt: '', updatedAt: '', lastLoginAt: null, autoApproveRequests: false, _count: { requests: 0 } }] }, + mutate: mutateUsers, + }); + swrState.set('/api/admin/users/pending', { data: { users: [] }, mutate: mutatePending }); + swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: false }, mutate: mutateGlobal }); + + fetchJSONMock.mockResolvedValueOnce({ success: true }); + + render(); + + fireEvent.click(await screen.findByText('Auto-Approve All Requests')); + + await waitFor(() => { + expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/settings/auto-approve', { + method: 'PATCH', + body: JSON.stringify({ autoApproveRequests: true }), + }); + expect(mutateGlobal).toHaveBeenCalled(); + expect(mutateUsers).toHaveBeenCalled(); + }); + }); + + it('edits a user role and saves changes', async () => { + const mutateUsers = vi.fn(); + + swrState.set('/api/admin/users', { + data: { + users: [ + { + id: 'u2', + plexUsername: 'LocalUser', + plexId: 'local-1', + role: 'user', + isSetupAdmin: false, + authProvider: 'local', + plexEmail: 'local@example.com', + avatarUrl: null, + createdAt: '', + updatedAt: '', + lastLoginAt: null, + autoApproveRequests: false, + _count: { requests: 2 }, + }, + ], + }, + mutate: mutateUsers, + }); + swrState.set('/api/admin/users/pending', { data: { users: [] }, mutate: vi.fn() }); + swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: true }, mutate: vi.fn() }); + + fetchJSONMock.mockResolvedValueOnce({ success: true }); + + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Edit Role' })); + fireEvent.click(screen.getByRole('radio', { name: /Admin/i })); + fireEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + await waitFor(() => { + expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/u2', { + method: 'PUT', + body: JSON.stringify({ role: 'admin' }), + }); + expect(mutateUsers).toHaveBeenCalled(); + }); + }); + + it('approves a pending user and refreshes lists', async () => { + const mutateUsers = vi.fn(); + const mutatePending = vi.fn(); + + swrState.set('/api/admin/users', { data: { users: [] }, mutate: mutateUsers }); + swrState.set('/api/admin/users/pending', { + data: { + users: [{ id: 'p1', plexUsername: 'Pending', plexEmail: null, authProvider: 'local', createdAt: new Date().toISOString() }], + }, + mutate: mutatePending, + }); + swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: true }, mutate: vi.fn() }); + + fetchJSONMock.mockResolvedValueOnce({ success: true }); + + render(); + + const approveButtons = await screen.findAllByRole('button', { name: 'Approve' }); + fireEvent.click(approveButtons[0]); + + const confirmButtons = await screen.findAllByRole('button', { name: 'Approve' }); + fireEvent.click(confirmButtons[1]); + + await waitFor(() => { + expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/p1/approve', { + method: 'POST', + body: JSON.stringify({ approve: true }), + }); + expect(mutatePending).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/app/bookdate.page.test.tsx b/tests/app/bookdate.page.test.tsx new file mode 100644 index 0000000..6c61cb7 --- /dev/null +++ b/tests/app/bookdate.page.test.tsx @@ -0,0 +1,294 @@ +/** + * Component: BookDate Page Tests + * Documentation: documentation/features/bookdate.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetMockRouter, routerMock } from '../helpers/mock-next-navigation'; + +vi.mock('@/components/layout/Header', () => ({ + Header: () =>
, +})); + +vi.mock('@/components/bookdate/LoadingScreen', () => ({ + LoadingScreen: () =>
, +})); + +vi.mock('@/components/bookdate/SettingsWidget', () => ({ + SettingsWidget: ({ + isOpen, + isOnboarding, + onOnboardingComplete, + }: { + isOpen: boolean; + isOnboarding: boolean; + onOnboardingComplete: () => void; + }) => ( +
+ +
+ ), +})); + +vi.mock('@/components/bookdate/CardStack', () => ({ + CardStack: ({ + recommendations, + onSwipe, + onSwipeComplete, + }: { + recommendations: any[]; + onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void; + onSwipeComplete: () => void; + }) => ( +
+
{recommendations.length}
+ + +
+ ), +})); + +const makeJsonResponse = (body: any, ok: boolean = true) => ({ + ok, + status: ok ? 200 : 500, + json: async () => body, +}); + +describe('BookDatePage', () => { + beforeEach(() => { + resetMockRouter(); + localStorage.clear(); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('redirects to login when no access token is available', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const { default: BookDatePage } = await import('@/app/bookdate/page'); + render(); + + await waitFor(() => { + expect(routerMock.push).toHaveBeenCalledWith('/login'); + }); + }); + + it('shows onboarding settings when onboarding is incomplete', async () => { + localStorage.setItem('accessToken', 'token'); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/bookdate/preferences') { + return makeJsonResponse({ onboardingComplete: false }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: BookDatePage } = await import('@/app/bookdate/page'); + render(); + + expect(await screen.findByText('Welcome to BookDate!')).toBeInTheDocument(); + expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'true'); + }); + + it('renders an error state when recommendations fetch fails', async () => { + localStorage.setItem('accessToken', 'token'); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/bookdate/preferences') { + return makeJsonResponse({ onboardingComplete: true }); + } + if (url === '/api/bookdate/recommendations') { + return makeJsonResponse({ error: 'bad' }, false); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: BookDatePage } = await import('@/app/bookdate/page'); + render(); + + expect(await screen.findByText(/Could not load recommendations/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Try Again' })); + + await waitFor(() => { + const recCalls = fetchMock.mock.calls.filter(([input]) => String(input).includes('/api/bookdate/recommendations')); + expect(recCalls.length).toBeGreaterThan(1); + }); + }); + + it('shows empty state and triggers recommendation generation', async () => { + localStorage.setItem('accessToken', 'token'); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/bookdate/preferences') { + return makeJsonResponse({ onboardingComplete: true }); + } + if (url === '/api/bookdate/recommendations') { + return makeJsonResponse({ recommendations: [] }); + } + if (url === '/api/bookdate/generate') { + return makeJsonResponse({ recommendations: [{ id: 'rec-1' }] }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: BookDatePage } = await import('@/app/bookdate/page'); + render(); + + const generateButton = await screen.findByRole('button', { name: 'Get More Recommendations' }); + fireEvent.click(generateButton); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/bookdate/generate', + expect.objectContaining({ method: 'POST' }) + ); + }); + }); + + it('posts swipes and shows undo option', async () => { + localStorage.setItem('accessToken', 'token'); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/bookdate/preferences') { + return makeJsonResponse({ onboardingComplete: true }); + } + if (url === '/api/bookdate/recommendations') { + return makeJsonResponse({ recommendations: [{ id: 'rec-1' }, { id: 'rec-2' }] }); + } + if (url === '/api/bookdate/swipe') { + return makeJsonResponse({ success: true }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: BookDatePage } = await import('@/app/bookdate/page'); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Swipe Left' })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/bookdate/swipe', + expect.objectContaining({ method: 'POST' }) + ); + }); + + expect(await screen.findByRole('button', { name: /Undo/i })).toBeInTheDocument(); + }); + + it('opens settings when the settings button is clicked', async () => { + localStorage.setItem('accessToken', 'token'); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/bookdate/preferences') { + return makeJsonResponse({ onboardingComplete: true }); + } + if (url === '/api/bookdate/recommendations') { + return makeJsonResponse({ recommendations: [{ id: 'rec-1' }] }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: BookDatePage } = await import('@/app/bookdate/page'); + render(); + + expect(await screen.findByTestId('card-count')).toHaveTextContent('1'); + expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'false'); + + fireEvent.click(screen.getByRole('button', { name: 'Open settings' })); + + expect(screen.getByTestId('settings-widget')).toHaveAttribute('data-open', 'true'); + }); + + it('undoes a swipe and reloads recommendations', async () => { + localStorage.setItem('accessToken', 'token'); + let recommendationsCall = 0; + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/bookdate/preferences') { + return makeJsonResponse({ onboardingComplete: true }); + } + if (url === '/api/bookdate/recommendations') { + recommendationsCall += 1; + if (recommendationsCall === 1) { + return makeJsonResponse({ recommendations: [{ id: 'rec-1' }, { id: 'rec-2' }] }); + } + return makeJsonResponse({ recommendations: [{ id: 'rec-restored' }] }); + } + if (url === '/api/bookdate/swipe') { + return makeJsonResponse({ success: true }); + } + if (url === '/api/bookdate/undo') { + return makeJsonResponse({ success: true }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: BookDatePage } = await import('@/app/bookdate/page'); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Swipe Left' })); + + const undoButton = await screen.findByRole('button', { name: /Undo/i }); + fireEvent.click(undoButton); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/bookdate/undo', + expect.objectContaining({ method: 'POST' }) + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('card-count')).toHaveTextContent('1'); + }); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: /Undo/i })).toBeNull(); + }); + }); +}); diff --git a/tests/app/home.page.test.tsx b/tests/app/home.page.test.tsx new file mode 100644 index 0000000..f84f7c0 --- /dev/null +++ b/tests/app/home.page.test.tsx @@ -0,0 +1,119 @@ +/** + * Component: Home Page Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetMockAuthState } from '../helpers/mock-auth'; +import { resetMockRouter } from '../helpers/mock-next-navigation'; + +const useAudiobooksMock = vi.hoisted(() => vi.fn()); +const usePreferencesMock = vi.hoisted(() => ({ + cardSize: 5, + setCardSize: vi.fn(), +})); + +vi.mock('@/lib/hooks/useAudiobooks', () => ({ + useAudiobooks: useAudiobooksMock, +})); + +vi.mock('@/contexts/PreferencesContext', () => ({ + usePreferences: () => usePreferencesMock, +})); + +vi.mock('@/components/auth/ProtectedRoute', () => ({ + ProtectedRoute: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock('@/components/layout/Header', () => ({ + Header: () =>
, +})); + +vi.mock('@/components/audiobooks/AudiobookGrid', () => ({ + AudiobookGrid: ({ audiobooks, cardSize }: { audiobooks: any[]; cardSize?: number }) => ( +
+ {audiobooks.map((book) => ( +
{book.title}
+ ))} +
+ ), +})); + +vi.mock('@/components/ui/CardSizeControls', () => ({ + CardSizeControls: ({ size }: { size: number }) =>
, +})); + +vi.mock('@/components/ui/StickyPagination', () => ({ + StickyPagination: ({ + label, + onPageChange, + }: { + label: string; + onPageChange: (page: number) => void; + }) => ( + + ), +})); + +describe('HomePage', () => { + beforeEach(() => { + resetMockAuthState(); + resetMockRouter(); + useAudiobooksMock.mockReset(); + usePreferencesMock.cardSize = 5; + usePreferencesMock.setCardSize.mockReset(); + vi.resetModules(); + }); + + it('renders empty state messaging for popular audiobooks', async () => { + useAudiobooksMock.mockImplementation((category: string) => { + if (category === 'popular') { + return { + audiobooks: [], + isLoading: false, + totalPages: 1, + message: 'Nothing here', + }; + } + return { + audiobooks: [{ asin: 'n1', title: 'New Release', author: 'Author' }], + isLoading: false, + totalPages: 2, + message: null, + }; + }); + + const { default: HomePage } = await import('@/app/page'); + render(); + + expect(screen.getByText('No popular audiobooks found')).toBeInTheDocument(); + expect(screen.getByText('Nothing here')).toBeInTheDocument(); + expect(screen.getByText('New Release')).toBeInTheDocument(); + }); + + it('updates pagination when the sticky controls request a new page', async () => { + useAudiobooksMock.mockImplementation((category: string, _limit: number, page: number) => { + return { + audiobooks: [{ asin: `${category}-${page}`, title: `${category}-${page}`, author: 'Author' }], + isLoading: false, + totalPages: 3, + message: null, + }; + }); + + const { default: HomePage } = await import('@/app/page'); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' })); + + await waitFor(() => { + expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2); + }); + }); +}); diff --git a/tests/app/login.page.test.tsx b/tests/app/login.page.test.tsx new file mode 100644 index 0000000..fa3fecc --- /dev/null +++ b/tests/app/login.page.test.tsx @@ -0,0 +1,523 @@ +/** + * Component: Login Page Tests + * Documentation: documentation/frontend/pages/login.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { resetMockRouter, routerMock, setMockSearchParams } from '../helpers/mock-next-navigation'; +import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth'; + +const makeJsonResponse = (body: any, ok: boolean = true) => ({ + ok, + status: ok ? 200 : 500, + json: async () => body, +}); + +const baseProviders = { + backendMode: 'plex', + providers: ['plex'], + registrationEnabled: false, + hasLocalUsers: false, + oidcProviderName: null, + localLoginDisabled: false, + automationEnabled: false, +}; + +describe('LoginPage', () => { + beforeEach(() => { + resetMockRouter(); + resetMockAuthState(); + localStorage.clear(); + setMockSearchParams(''); + window.innerWidth = 1024; + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders description based on backend mode and automation flag', async () => { + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') { + return makeJsonResponse({ + ...baseProviders, + backendMode: 'audiobookshelf', + automationEnabled: true, + }); + } + if (url === '/api/audiobooks/covers') { + return makeJsonResponse({ success: true, covers: [] }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + expect( + await screen.findByText( + "Request audiobooks and they'll automatically download and appear in your Audiobookshelf library" + ) + ).toBeInTheDocument(); + }); + + it('redirects to intended page when user is already logged in', async () => { + setMockAuthState({ + user: { id: 'user-1', plexId: 'plex-1', username: 'user', role: 'user' }, + isLoading: false, + }); + setMockSearchParams('redirect=/requests'); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); + if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + await waitFor(() => { + expect(routerMock.push).toHaveBeenCalledWith('/requests'); + }); + }); + + it('handles Plex login with popup flow', async () => { + const loginMock = vi.fn().mockResolvedValue(undefined); + setMockAuthState({ login: loginMock, isLoading: false }); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); + if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); + if (url === '/api/auth/plex/login') { + return makeJsonResponse({ pinId: 123, authUrl: 'http://plex/auth' }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + const closeMock = vi.fn(); + const openMock = vi.fn().mockReturnValue({ close: closeMock }); + vi.stubGlobal('open', openMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + const loginButton = await screen.findByRole('button', { name: 'Login with Plex' }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(loginMock).toHaveBeenCalledWith(123); + expect(routerMock.push).toHaveBeenCalledWith('/'); + }); + expect(openMock).toHaveBeenCalledWith( + 'http://plex/auth', + 'plex-auth', + 'width=600,height=700,scrollbars=yes,resizable=yes' + ); + expect(closeMock).toHaveBeenCalled(); + }); + + it('shows an error when Plex login popup is blocked', async () => { + const loginMock = vi.fn().mockResolvedValue(undefined); + setMockAuthState({ login: loginMock, isLoading: false }); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); + if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); + if (url === '/api/auth/plex/login') { + return makeJsonResponse({ pinId: 456, authUrl: 'http://plex/auth' }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('open', vi.fn().mockReturnValue(null)); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + const loginButton = await screen.findByRole('button', { name: 'Login with Plex' }); + fireEvent.click(loginButton); + + expect(await screen.findByText(/Popup was blocked/i)).toBeInTheDocument(); + expect(loginMock).not.toHaveBeenCalled(); + }); + + it('logs in with local credentials and stores tokens', async () => { + const setAuthDataMock = vi.fn(); + setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); + + const providers = { + ...baseProviders, + providers: ['local'], + }; + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(providers); + if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); + if (url === '/api/auth/local/login') { + return makeJsonResponse({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + user: { id: 'user-1', username: 'local-user', role: 'admin' }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + const username = await screen.findByLabelText('Username'); + const password = screen.getByLabelText('Password'); + + fireEvent.change(username, { target: { value: 'admin' } }); + fireEvent.change(password, { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: 'Login' })); + + await waitFor(() => { + expect(setAuthDataMock).toHaveBeenCalledWith( + { id: 'user-1', username: 'local-user', role: 'admin' }, + 'access-token' + ); + expect(routerMock.push).toHaveBeenCalledWith('/'); + }); + + expect(localStorage.getItem('accessToken')).toBe('access-token'); + expect(localStorage.getItem('refreshToken')).toBe('refresh-token'); + }); + + it('validates registration passwords before sending request', async () => { + const providers = { + ...baseProviders, + providers: ['local'], + registrationEnabled: true, + }; + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(providers); + if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + const registerToggle = await screen.findByRole('button', { name: /Don't have an account\? Register/i }); + fireEvent.click(registerToggle); + + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } }); + const passwordInputs = screen.getAllByLabelText('Password'); + fireEvent.change(passwordInputs[0], { target: { value: 'password1' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password2' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Register' })); + + expect(await screen.findByText('Passwords do not match')).toBeInTheDocument(); + }); + + it('renders an OIDC login button and redirects to the provider', async () => { + const providers = { + ...baseProviders, + providers: ['oidc'], + oidcProviderName: 'Auth0', + }; + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(providers); + if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + expect(await screen.findByRole('button', { name: 'Login with Auth0' })).toBeInTheDocument(); + expect( + screen.getByText("You'll be redirected to Auth0 to authenticate") + ).toBeInTheDocument(); + }); + + it('logs in via admin credentials when Plex mode exposes admin login', async () => { + const setAuthDataMock = vi.fn(); + setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); + if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); + if (url === '/api/auth/admin/login') { + return makeJsonResponse({ + accessToken: 'admin-access', + refreshToken: 'admin-refresh', + user: { id: 'admin-1', username: 'admin', role: 'admin' }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + const toggleButton = await screen.findByRole('button', { name: 'Admin Login' }); + fireEvent.click(toggleButton); + + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: 'Login as Admin' })); + + await waitFor(() => { + expect(setAuthDataMock).toHaveBeenCalledWith( + { id: 'admin-1', username: 'admin', role: 'admin' }, + 'admin-access' + ); + expect(routerMock.push).toHaveBeenCalledWith('/'); + }); + + expect(localStorage.getItem('accessToken')).toBe('admin-access'); + expect(localStorage.getItem('refreshToken')).toBe('admin-refresh'); + }); + + it('renders book cover images when the covers API returns data', async () => { + window.innerWidth = 500; + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); + if (url === '/api/audiobooks/covers') { + return makeJsonResponse({ + success: true, + covers: [ + { + asin: 'asin-1', + title: 'Book One', + author: 'Author', + coverUrl: '/cover.jpg', + }, + ], + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + expect(await screen.findByAltText('Book One')).toBeInTheDocument(); + }); + + it('shows pending approval alert when admin login returns pending status', async () => { + const setAuthDataMock = vi.fn(); + setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); + if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); + if (url === '/api/auth/admin/login') { + return makeJsonResponse({ pendingApproval: true }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Admin Login' })); + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: 'Login as Admin' })); + + expect(await screen.findByText('Account Pending Approval')).toBeInTheDocument(); + expect(setAuthDataMock).not.toHaveBeenCalled(); + }); + + it('shows registration pending alert when registration needs approval', async () => { + const providers = { + ...baseProviders, + providers: ['local'], + registrationEnabled: true, + }; + const setAuthDataMock = vi.fn(); + setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(providers); + if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); + if (url === '/api/auth/register') { + return makeJsonResponse({ pendingApproval: true }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + fireEvent.click( + await screen.findByRole('button', { name: /Don't have an account\? Register/i }) + ); + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password1' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password1' } }); + fireEvent.click(screen.getByRole('button', { name: 'Register' })); + + expect(await screen.findByText('Registration Pending')).toBeInTheDocument(); + expect(setAuthDataMock).not.toHaveBeenCalled(); + }); + + it('auto-logs in after successful registration', async () => { + const providers = { + ...baseProviders, + providers: ['local'], + registrationEnabled: true, + }; + const setAuthDataMock = vi.fn(); + setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(providers); + if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); + if (url === '/api/auth/register') { + return makeJsonResponse({ + success: true, + accessToken: 'reg-access', + refreshToken: 'reg-refresh', + user: { id: 'user-3', username: 'new-user', role: 'user' }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + fireEvent.click( + await screen.findByRole('button', { name: /Don't have an account\? Register/i }) + ); + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password1' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password1' } }); + fireEvent.click(screen.getByRole('button', { name: 'Register' })); + + await waitFor(() => { + expect(setAuthDataMock).toHaveBeenCalledWith( + { id: 'user-3', username: 'new-user', role: 'user' }, + 'reg-access' + ); + expect(routerMock.push).toHaveBeenCalledWith('/'); + }); + + expect(localStorage.getItem('accessToken')).toBe('reg-access'); + expect(localStorage.getItem('refreshToken')).toBe('reg-refresh'); + }); + + it('falls back to Plex mode when providers fetch fails', async () => { + const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') { + throw new Error('providers down'); + } + if (url === '/api/audiobooks/covers') { + throw new Error('covers down'); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + expect(await screen.findByRole('button', { name: 'Login with Plex' })).toBeInTheDocument(); + expect(errorMock).toHaveBeenCalledWith('Failed to fetch auth providers:', expect.any(Error)); + expect(errorMock).toHaveBeenCalledWith('Failed to fetch book covers:', expect.any(Error)); + }); + + it('processes mobile auth data from URL hash', async () => { + const setAuthDataMock = vi.fn(); + setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); + setMockSearchParams('auth=success&redirect=/requests'); + + const authData = { + accessToken: 'mobile-access', + refreshToken: 'mobile-refresh', + user: { id: 'user-9', username: 'mobile-user', role: 'user' }, + }; + window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`; + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); + if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + await waitFor(() => { + expect(setAuthDataMock).toHaveBeenCalledWith(authData.user, authData.accessToken); + expect(routerMock.push).toHaveBeenCalledWith('/requests'); + }); + + expect(localStorage.getItem('accessToken')).toBe('mobile-access'); + expect(localStorage.getItem('refreshToken')).toBe('mobile-refresh'); + expect(window.location.hash).toBe(''); + }); + + it('shows error message from query string', async () => { + setMockSearchParams('error=Access%20Denied'); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/providers') return makeJsonResponse(baseProviders); + if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] }); + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: LoginPage } = await import('@/app/login/page'); + render(); + + expect(await screen.findByText('Access Denied')).toBeInTheDocument(); + }); +}); diff --git a/tests/app/profile.page.test.tsx b/tests/app/profile.page.test.tsx new file mode 100644 index 0000000..ffee3d4 --- /dev/null +++ b/tests/app/profile.page.test.tsx @@ -0,0 +1,125 @@ +/** + * Component: Profile Page Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth'; +import { resetMockRouter } from '../helpers/mock-next-navigation'; + +const useRequestsMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/hooks/useRequests', () => ({ + useRequests: useRequestsMock, +})); + +vi.mock('@/components/layout/Header', () => ({ + Header: () =>
, +})); + +vi.mock('@/components/requests/RequestCard', () => ({ + RequestCard: ({ request, showActions }: { request: any; showActions?: boolean }) => ( +
+ {request.id} +
+ ), +})); + +const getStatValue = (label: string) => { + const labelNode = screen.getByText(label); + const container = labelNode.parentElement; + const valueNode = container?.querySelector('p:nth-of-type(2)'); + return valueNode?.textContent; +}; + +describe('ProfilePage', () => { + beforeEach(() => { + resetMockAuthState(); + resetMockRouter(); + useRequestsMock.mockReset(); + vi.resetModules(); + }); + + it('prompts for authentication when no user is available', async () => { + setMockAuthState({ user: null }); + useRequestsMock.mockReturnValue({ requests: [], isLoading: false }); + + const { default: ProfilePage } = await import('@/app/profile/page'); + render(); + + expect(screen.getByText('Authentication Required')).toBeInTheDocument(); + expect(screen.getByText('Please log in to view your profile')).toBeInTheDocument(); + }); + + it('calculates stats and orders recent requests', async () => { + setMockAuthState({ + user: { + id: 'user-1', + plexId: 'plex-1', + username: 'user', + role: 'user', + }, + isLoading: false, + }); + + const requests = [ + { id: 'req-1', status: 'pending', createdAt: '2025-01-01T10:00:00Z', audiobook: {} }, + { id: 'req-2', status: 'awaiting_search', createdAt: '2025-01-02T10:00:00Z', audiobook: {} }, + { id: 'req-3', status: 'available', createdAt: '2025-01-03T10:00:00Z', audiobook: {} }, + { id: 'req-4', status: 'failed', createdAt: '2025-01-04T10:00:00Z', audiobook: {} }, + { id: 'req-5', status: 'cancelled', createdAt: '2025-01-05T10:00:00Z', audiobook: {} }, + { id: 'req-6', status: 'searching', createdAt: '2025-01-06T10:00:00Z', audiobook: {} }, + ]; + + useRequestsMock.mockReturnValue({ requests, isLoading: false }); + + const { default: ProfilePage } = await import('@/app/profile/page'); + render(); + + expect(getStatValue('Total')).toBe('6'); + expect(getStatValue('Active')).toBe('2'); + expect(getStatValue('Waiting')).toBe('1'); + expect(getStatValue('Completed')).toBe('1'); + expect(getStatValue('Failed')).toBe('1'); + expect(getStatValue('Cancelled')).toBe('1'); + + const cards = screen.getAllByTestId('request-card'); + expect(cards).toHaveLength(5); + expect(cards[0]).toHaveAttribute('data-request-id', 'req-6'); + }); + + it('shows active downloads when downloading requests exist', async () => { + setMockAuthState({ + user: { + id: 'user-2', + plexId: 'plex-2', + username: 'download-user', + role: 'user', + }, + isLoading: false, + }); + + const requests = [ + { id: 'req-downloading', status: 'downloading', createdAt: '2025-02-01T10:00:00Z', audiobook: {} }, + { id: 'req-processing', status: 'processing', createdAt: '2025-02-02T10:00:00Z', audiobook: {} }, + { id: 'req-pending', status: 'pending', createdAt: '2025-02-03T10:00:00Z', audiobook: {} }, + ]; + + useRequestsMock.mockReturnValue({ requests, isLoading: false }); + + const { default: ProfilePage } = await import('@/app/profile/page'); + render(); + + expect(screen.getByText('Active Downloads')).toBeInTheDocument(); + const cards = screen.getAllByTestId('request-card'); + expect(cards.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/app/requests.page.test.tsx b/tests/app/requests.page.test.tsx new file mode 100644 index 0000000..1c224cf --- /dev/null +++ b/tests/app/requests.page.test.tsx @@ -0,0 +1,91 @@ +/** + * Component: Requests Page Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth'; +import { resetMockRouter } from '../helpers/mock-next-navigation'; + +const useRequestsMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/hooks/useRequests', () => ({ + useRequests: useRequestsMock, +})); + +vi.mock('@/components/layout/Header', () => ({ + Header: () =>
, +})); + +vi.mock('@/components/requests/RequestCard', () => ({ + RequestCard: ({ request, showActions }: { request: any; showActions?: boolean }) => ( +
+ {request.id} +
+ ), +})); + +describe('RequestsPage', () => { + beforeEach(() => { + resetMockAuthState(); + resetMockRouter(); + useRequestsMock.mockReset(); + vi.resetModules(); + }); + + it('prompts for authentication when no user is available', async () => { + setMockAuthState({ user: null }); + useRequestsMock.mockReturnValue({ requests: [], isLoading: false }); + + const { default: RequestsPage } = await import('@/app/requests/page'); + render(); + + expect(screen.getByText('Authentication Required')).toBeInTheDocument(); + expect(screen.getByText('Please log in to view your audiobook requests')).toBeInTheDocument(); + }); + + it('filters requests by status and updates tab counts', async () => { + setMockAuthState({ + user: { id: 'user-1', plexId: 'plex-1', username: 'user', role: 'user' }, + isLoading: false, + }); + + const requests = [ + { id: 'req-active', status: 'pending', audiobook: { title: 'Active', author: 'Author' } }, + { id: 'req-wait', status: 'awaiting_search', audiobook: { title: 'Wait', author: 'Author' } }, + { id: 'req-complete', status: 'downloaded', audiobook: { title: 'Done', author: 'Author' } }, + { id: 'req-failed', status: 'failed', audiobook: { title: 'Fail', author: 'Author' } }, + ]; + + useRequestsMock.mockReturnValue({ requests, isLoading: false }); + + const { default: RequestsPage } = await import('@/app/requests/page'); + render(); + + const activeTab = screen.getByRole('button', { name: /Active/i }); + const waitingTab = screen.getByRole('button', { name: /Waiting/i }); + + expect(activeTab).toHaveTextContent('(1)'); + expect(waitingTab).toHaveTextContent('(1)'); + + fireEvent.click(activeTab); + + const activeCards = screen.getAllByTestId('request-card'); + expect(activeCards).toHaveLength(1); + expect(activeCards[0]).toHaveAttribute('data-status', 'pending'); + + fireEvent.click(waitingTab); + + const waitingCards = screen.getAllByTestId('request-card'); + expect(waitingCards).toHaveLength(1); + expect(waitingCards[0]).toHaveAttribute('data-status', 'awaiting_search'); + }); +}); diff --git a/tests/app/search.page.test.tsx b/tests/app/search.page.test.tsx new file mode 100644 index 0000000..c89d991 --- /dev/null +++ b/tests/app/search.page.test.tsx @@ -0,0 +1,125 @@ +/** + * Component: Search Page Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetMockAuthState } from '../helpers/mock-auth'; +import { resetMockRouter } from '../helpers/mock-next-navigation'; + +const useSearchMock = vi.hoisted(() => vi.fn()); +const usePreferencesMock = vi.hoisted(() => ({ + cardSize: 5, + setCardSize: vi.fn(), +})); + +vi.mock('@/lib/hooks/useAudiobooks', () => ({ + useSearch: useSearchMock, +})); + +vi.mock('@/contexts/PreferencesContext', () => ({ + usePreferences: () => usePreferencesMock, +})); + +vi.mock('@/components/auth/ProtectedRoute', () => ({ + ProtectedRoute: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock('@/components/layout/Header', () => ({ + Header: () =>
, +})); + +vi.mock('@/components/audiobooks/AudiobookGrid', () => ({ + AudiobookGrid: ({ + audiobooks, + emptyMessage, + cardSize, + }: { + audiobooks: any[]; + emptyMessage: string; + cardSize?: number; + }) => ( +
+ {emptyMessage} +
+ ), +})); + +vi.mock('@/components/ui/CardSizeControls', () => ({ + CardSizeControls: ({ size }: { size: number }) =>
, +})); + +describe('SearchPage', () => { + beforeEach(() => { + resetMockAuthState(); + resetMockRouter(); + useSearchMock.mockReset(); + usePreferencesMock.cardSize = 5; + usePreferencesMock.setCardSize.mockReset(); + vi.useFakeTimers(); + vi.resetModules(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows the empty state before a search query is entered', async () => { + useSearchMock.mockReturnValue({ + results: [], + totalResults: 0, + hasMore: false, + isLoading: false, + }); + + const { default: SearchPage } = await import('@/app/search/page'); + render(); + + expect(screen.getByText('Start typing to search for audiobooks')).toBeInTheDocument(); + expect(useSearchMock).toHaveBeenCalledWith('', 1); + }); + + it('debounces search input and loads more results', async () => { + useSearchMock.mockImplementation((query: string, page: number) => { + if (!query) { + return { results: [], totalResults: 0, hasMore: false, isLoading: false }; + } + if (page === 1) { + return { + results: [{ asin: 'a1', title: 'Book One', author: 'Author' }], + totalResults: 2, + hasMore: true, + isLoading: false, + }; + } + return { + results: [{ asin: 'a2', title: 'Book Two', author: 'Author' }], + totalResults: 2, + hasMore: false, + isLoading: false, + }; + }); + + const { default: SearchPage } = await import('@/app/search/page'); + render(); + + const input = screen.getByPlaceholderText('Search by title, author, or narrator...'); + fireEvent.change(input, { target: { value: 'Dune' } }); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + expect(screen.getByText('Search Results')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Load More Results' })).toBeInTheDocument(); + expect(screen.getByTestId('grid')).toHaveAttribute('data-count', '1'); + + fireEvent.click(screen.getByRole('button', { name: 'Load More Results' })); + + expect(useSearchMock).toHaveBeenCalledWith('Dune', 2); + }); +}); diff --git a/tests/app/select-profile.page.test.tsx b/tests/app/select-profile.page.test.tsx new file mode 100644 index 0000000..85c242c --- /dev/null +++ b/tests/app/select-profile.page.test.tsx @@ -0,0 +1,160 @@ +/** + * Component: Select Profile Page Tests + * Documentation: documentation/backend/services/auth.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth'; +import { resetMockRouter, routerMock, setMockSearchParams } from '../helpers/mock-next-navigation'; + +const makeJsonResponse = (body: any, ok = true, status = 200) => ({ + ok, + status, + json: async () => body, +}); + +describe('SelectProfilePage', () => { + beforeEach(() => { + resetMockAuthState(); + resetMockRouter(); + localStorage.clear(); + sessionStorage.clear(); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('shows an error when session info is missing', async () => { + setMockSearchParams(''); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const { default: SelectProfilePage } = await import('@/app/auth/select-profile/page'); + render(); + + expect(await screen.findByText('Invalid session. Please try logging in again.')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Back to Login' })); + expect(routerMock.push).toHaveBeenCalledWith('/login'); + }); + + it('selects an unprotected profile and stores auth data', async () => { + sessionStorage.setItem('plex_main_token', 'main-token'); + setMockSearchParams('pinId=123'); + + const setAuthDataMock = vi.fn(); + setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); + + const profiles = [ + { + id: 'profile-1', + uuid: 'uuid-1', + title: 'User', + friendlyName: 'Primary', + username: 'primary', + email: 'primary@example.com', + thumb: 'http://thumb', + hasPassword: false, + restricted: false, + admin: true, + guest: false, + protected: false, + }, + ]; + + const fetchMock = vi.fn(async (input: RequestInfo, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/plex/home-users') { + return makeJsonResponse({ users: profiles }); + } + if (url === '/api/auth/plex/switch-profile') { + return makeJsonResponse({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + user: { id: 'user-1', plexId: 'plex-1', username: 'primary', role: 'user' }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: SelectProfilePage } = await import('@/app/auth/select-profile/page'); + render(); + + const profileButton = await screen.findByRole('button', { name: /Primary/ }); + fireEvent.click(profileButton); + + await waitFor(() => { + expect(setAuthDataMock).toHaveBeenCalledWith( + { id: 'user-1', plexId: 'plex-1', username: 'primary', role: 'user' }, + 'access-token' + ); + expect(routerMock.push).toHaveBeenCalledWith('/'); + }); + + const body = JSON.parse((fetchMock.mock.calls[1][1] as RequestInit).body as string); + expect(body.userId).toBe('profile-1'); + expect(body.pinId).toBe('123'); + expect(localStorage.getItem('accessToken')).toBe('access-token'); + expect(localStorage.getItem('refreshToken')).toBe('refresh-token'); + }); + + it('prompts for a PIN and handles invalid submissions', async () => { + sessionStorage.setItem('plex_main_token', 'main-token'); + setMockSearchParams('pinId=555'); + + const setAuthDataMock = vi.fn(); + setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false }); + + const profiles = [ + { + id: 'profile-2', + uuid: 'uuid-2', + title: 'Protected', + friendlyName: 'Protected', + username: 'protected', + email: 'protected@example.com', + thumb: '', + hasPassword: true, + restricted: false, + admin: false, + guest: false, + protected: true, + }, + ]; + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/auth/plex/home-users') { + return makeJsonResponse({ users: profiles }); + } + if (url === '/api/auth/plex/switch-profile') { + return makeJsonResponse({ message: 'Invalid PIN' }, false, 401); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { default: SelectProfilePage } = await import('@/app/auth/select-profile/page'); + render(); + + const profileButton = await screen.findByRole('button', { name: /Protected/ }); + fireEvent.click(profileButton); + + const pinInput = await screen.findByPlaceholderText('Enter PIN'); + fireEvent.change(pinInput, { target: { value: '1234' } }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + expect(await screen.findByText('Invalid PIN. Please try again.')).toBeInTheDocument(); + expect((pinInput as HTMLInputElement).value).toBe(''); + expect(setAuthDataMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/app/setup.page.test.tsx b/tests/app/setup.page.test.tsx new file mode 100644 index 0000000..0f5328a --- /dev/null +++ b/tests/app/setup.page.test.tsx @@ -0,0 +1,263 @@ +/** + * Component: Setup Wizard Page Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import path from 'path'; +import { resetMockRouter } from '../helpers/mock-next-navigation'; + +const mockSetupModules = () => { + vi.doMock(path.resolve('src/app/setup/components/WizardLayout.tsx'), () => ({ + WizardLayout: ({ + children, + currentStep, + totalSteps, + }: { + children: React.ReactNode; + currentStep: number; + totalSteps: number; + }) => ( +
+ {children} +
+ ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/WelcomeStep.tsx'), () => ({ + WelcomeStep: ({ onNext }: { onNext: () => void }) => ( + + ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/BackendSelectionStep.tsx'), () => ({ + BackendSelectionStep: ({ + onNext, + onChange, + }: { + onNext: () => void; + onChange: (value: 'plex' | 'audiobookshelf') => void; + }) => ( +
+ + + +
+ ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/AdminAccountStep.tsx'), () => ({ + AdminAccountStep: ({ onNext }: { onNext: () => void }) => ( + + ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/PlexStep.tsx'), () => ({ + PlexStep: ({ onNext }: { onNext: () => void }) => ( + + ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/AudiobookshelfStep.tsx'), () => ({ + AudiobookshelfStep: ({ onNext }: { onNext: () => void }) => ( + + ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/AuthMethodStep.tsx'), () => ({ + AuthMethodStep: ({ + onNext, + onChange, + }: { + onNext: () => void; + onChange: (value: 'oidc' | 'manual' | 'both') => void; + }) => ( +
+ + +
+ ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/OIDCConfigStep.tsx'), () => ({ + OIDCConfigStep: ({ onNext }: { onNext: () => void }) => ( + + ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/RegistrationSettingsStep.tsx'), () => ({ + RegistrationSettingsStep: ({ onNext }: { onNext: () => void }) => ( + + ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/ProwlarrStep.tsx'), () => ({ + ProwlarrStep: ({ onNext }: { onNext: () => void }) => ( + + ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/DownloadClientStep.tsx'), () => ({ + DownloadClientStep: ({ onNext }: { onNext: () => void }) => ( + + ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/PathsStep.tsx'), () => ({ + PathsStep: ({ onNext }: { onNext: () => void }) => ( + + ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/BookDateStep.tsx'), () => ({ + BookDateStep: ({ onNext }: { onNext: () => void }) => ( + + ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/ReviewStep.tsx'), () => ({ + ReviewStep: ({ onComplete }: { onComplete: () => void }) => ( + + ), + })); + + vi.doMock(path.resolve('src/app/setup/steps/FinalizeStep.tsx'), () => ({ + FinalizeStep: ({ hasAdminTokens }: { hasAdminTokens: boolean }) => ( +
{hasAdminTokens ? 'admin' : 'oidc'}
+ ), + })); +}; + +const makeJsonResponse = (body: any, ok: boolean = true) => ({ + ok, + status: ok ? 200 : 500, + json: async () => body, +}); + +describe('SetupWizard', () => { + beforeEach(() => { + resetMockRouter(); + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('completes setup in Plex mode and stores admin tokens', async () => { + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/setup/complete') { + return makeJsonResponse({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + user: { id: 'admin-1', username: 'admin' }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + vi.resetModules(); + mockSetupModules(); + const { default: SetupWizard } = await import('@/app/setup/page'); + render(); + + for (let i = 0; i < 8; i += 1) { + fireEvent.click(await screen.findByRole('button', { name: 'Next' })); + } + + fireEvent.click(await screen.findByRole('button', { name: 'Complete' })); + + await waitFor(() => { + expect(localStorage.getItem('accessToken')).toBe('access-token'); + expect(screen.getByTestId('finalize')).toHaveTextContent('admin'); + }); + + const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(requestBody.backendMode).toBe('plex'); + expect(requestBody.admin).toBeDefined(); + expect(requestBody.plex).toBeDefined(); + }); + + it('completes setup in OIDC-only mode and clears tokens', async () => { + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.url; + if (url === '/api/setup/complete') { + return makeJsonResponse({ success: true }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + localStorage.setItem('accessToken', 'stale-token'); + + vi.resetModules(); + mockSetupModules(); + const { default: SetupWizard } = await import('@/app/setup/page'); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Next' })); + fireEvent.click(await screen.findByRole('button', { name: 'Choose ABS' })); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + + fireEvent.click(await screen.findByRole('button', { name: 'Next' })); + fireEvent.click(await screen.findByRole('button', { name: 'Choose OIDC' })); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + + for (let i = 0; i < 5; i += 1) { + fireEvent.click(await screen.findByRole('button', { name: 'Next' })); + } + + fireEvent.click(await screen.findByRole('button', { name: 'Complete' })); + + await waitFor(() => { + expect(localStorage.getItem('accessToken')).toBeNull(); + expect(screen.getByTestId('finalize')).toHaveTextContent('oidc'); + }); + + const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string); + expect(requestBody.backendMode).toBe('audiobookshelf'); + expect(requestBody.authMethod).toBe('oidc'); + expect(requestBody.audiobookshelf).toBeDefined(); + expect(requestBody.oidc).toBeDefined(); + expect(requestBody.admin).toBeUndefined(); + }); +}); diff --git a/tests/app/setup/components/WizardLayout.test.tsx b/tests/app/setup/components/WizardLayout.test.tsx new file mode 100644 index 0000000..27e85d4 --- /dev/null +++ b/tests/app/setup/components/WizardLayout.test.tsx @@ -0,0 +1,43 @@ +/** + * Component: Setup Wizard Layout Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +describe('WizardLayout', () => { + it('renders Plex steps and footer progress', async () => { + const { WizardLayout } = await import('@/app/setup/components/WizardLayout'); + + render( + +
Content
+
+ ); + + expect(screen.getByText('ReadMeABook Setup')).toBeInTheDocument(); + expect(screen.getByText('Plex')).toBeInTheDocument(); + expect(screen.getByText('Finalize')).toBeInTheDocument(); + expect(screen.getByText('Step 3 of 10')).toBeInTheDocument(); + }); + + it('renders Audiobookshelf steps based on auth method', async () => { + const { WizardLayout } = await import('@/app/setup/components/WizardLayout'); + + render( + +
Content
+
+ ); + + expect(screen.getByText('ABS')).toBeInTheDocument(); + expect(screen.getByText('Auth')).toBeInTheDocument(); + expect(screen.getByText('OIDC')).toBeInTheDocument(); + expect(screen.queryByText('Registration')).toBeNull(); + expect(screen.queryByText('Admin')).toBeNull(); + }); +}); diff --git a/tests/app/setup/initializing.page.test.tsx b/tests/app/setup/initializing.page.test.tsx new file mode 100644 index 0000000..2d373ae --- /dev/null +++ b/tests/app/setup/initializing.page.test.tsx @@ -0,0 +1,207 @@ +/** + * Component: Setup Initializing Page Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resetMockRouter, routerMock } from '../../helpers/mock-next-navigation'; + +describe('InitializingPage', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + localStorage.clear(); + window.location.hash = ''; + resetMockRouter(); + }); + + it('redirects to login when auth data is missing', async () => { + window.location.hash = ''; + const { default: InitializingPage } = await import('@/app/setup/initializing/page'); + + render(); + + await waitFor(() => { + expect(routerMock.push).toHaveBeenCalledWith( + '/login?error=Authentication%20data%20missing' + ); + }); + }); + + it('processes auth data and completes job monitoring', async () => { + vi.useFakeTimers(); + const authData = { + accessToken: 'token-123', + refreshToken: 'refresh-123', + user: { id: 'user-1', username: 'admin' }, + }; + window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`; + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url === '/api/admin/jobs') { + return { + ok: true, + json: async () => ({ + jobs: [ + { id: 'job-1', type: 'audible_refresh', lastRunJobId: 'run-1' }, + { id: 'job-2', type: 'plex_library_scan', lastRunJobId: 'run-2' }, + ], + }), + }; + } + if (url === '/api/admin/job-status/run-1' || url === '/api/admin/job-status/run-2') { + return { ok: true, json: async () => ({ job: { status: 'completed' } }) }; + } + return { ok: true, json: async () => ({}) }; + }); + vi.stubGlobal('fetch', fetchMock); + + const { default: InitializingPage } = await import('@/app/setup/initializing/page'); + + render(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(localStorage.getItem('accessToken')).toBe('token-123'); + expect(window.location.hash).toBe(''); + + const completedMessages = screen.getAllByText('Completed successfully'); + expect(completedMessages.length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled(); + }); + + it('marks jobs as error when no recent job is found', async () => { + vi.useFakeTimers(); + const authData = { + accessToken: 'token-123', + refreshToken: 'refresh-123', + user: { id: 'user-1', username: 'admin' }, + }; + window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`; + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url === '/api/admin/jobs') { + return { + ok: true, + json: async () => ({ + jobs: [ + { id: 'job-1', type: 'audible_refresh' }, + { id: 'job-2', type: 'plex_library_scan' }, + ], + }), + }; + } + return { ok: true, json: async () => ({}) }; + }); + vi.stubGlobal('fetch', fetchMock); + + const { default: InitializingPage } = await import('@/app/setup/initializing/page'); + + render(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(screen.getAllByText(/Job did not start/).length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled(); + }); + + it('redirects when auth data fails to parse', async () => { + window.location.hash = '#authData='; + const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const { default: InitializingPage } = await import('@/app/setup/initializing/page'); + + render(); + + await waitFor(() => { + expect(routerMock.push).toHaveBeenCalledWith( + '/login?error=Failed%20to%20process%20authentication' + ); + }); + expect(errorMock).toHaveBeenCalledWith( + '[Initializing] Failed to process auth data:', + expect.any(Error) + ); + }); + + it('marks jobs as error when scheduled jobs fetch fails', async () => { + vi.useFakeTimers(); + const authData = { + accessToken: 'token-123', + refreshToken: 'refresh-123', + user: { id: 'user-1', username: 'admin' }, + }; + window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`; + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url === '/api/admin/jobs') { + return { ok: false, json: async () => ({}) }; + } + return { ok: true, json: async () => ({}) }; + }); + vi.stubGlobal('fetch', fetchMock); + + const { default: InitializingPage } = await import('@/app/setup/initializing/page'); + + render(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(screen.getAllByText(/Failed to fetch job configuration/).length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled(); + }); + + it('marks jobs as failed when job status returns failed', async () => { + vi.useFakeTimers(); + const authData = { + accessToken: 'token-123', + refreshToken: 'refresh-123', + user: { id: 'user-1', username: 'admin' }, + }; + window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`; + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url === '/api/admin/jobs') { + return { + ok: true, + json: async () => ({ + jobs: [ + { id: 'job-1', type: 'audible_refresh', lastRunJobId: 'run-1' }, + { id: 'job-2', type: 'plex_library_scan', lastRunJobId: 'run-2' }, + ], + }), + }; + } + if (url === '/api/admin/job-status/run-1' || url === '/api/admin/job-status/run-2') { + return { ok: true, json: async () => ({ job: { status: 'failed' } }) }; + } + return { ok: true, json: async () => ({}) }; + }); + vi.stubGlobal('fetch', fetchMock); + + const { default: InitializingPage } = await import('@/app/setup/initializing/page'); + + render(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(screen.getAllByText(/Job failed to complete/).length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: 'Go to Homepage' })).toBeEnabled(); + }); +}); diff --git a/tests/app/setup/steps/AdminAccountStep.test.tsx b/tests/app/setup/steps/AdminAccountStep.test.tsx new file mode 100644 index 0000000..57a7f7e --- /dev/null +++ b/tests/app/setup/steps/AdminAccountStep.test.tsx @@ -0,0 +1,86 @@ +/** + * Component: Admin Account Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React, { useState } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { AdminAccountStep } from '@/app/setup/steps/AdminAccountStep'; + +const AdminAccountHarness = ({ + onNext, + onBack, + initialUsername = '', + initialPassword = '', +}: { + onNext: () => void; + onBack: () => void; + initialUsername?: string; + initialPassword?: string; +}) => { + const [adminUsername, setAdminUsername] = useState(initialUsername); + const [adminPassword, setAdminPassword] = useState(initialPassword); + + return ( + { + if (field === 'adminUsername') { + setAdminUsername(value); + } + if (field === 'adminPassword') { + setAdminPassword(value); + } + }} + onNext={onNext} + onBack={onBack} + /> + ); +}; + +describe('AdminAccountStep', () => { + it('shows validation errors and blocks next when invalid', async () => { + const onNext = vi.fn(); + const onBack = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(screen.getByText('Username must be at least 3 characters')).toBeInTheDocument(); + expect(screen.getByText('Password must be at least 8 characters')).toBeInTheDocument(); + expect(screen.getByText('Passwords do not match')).toBeInTheDocument(); + expect(onNext).not.toHaveBeenCalled(); + }); + + it('allows navigation when credentials are valid', async () => { + const onNext = vi.fn(); + const onBack = vi.fn(); + render( + + ); + + fireEvent.change(screen.getByLabelText('Confirm Password'), { + target: { value: 'supersecret' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(onNext).toHaveBeenCalled(); + }); +}); diff --git a/tests/app/setup/steps/AudiobookshelfStep.test.tsx b/tests/app/setup/steps/AudiobookshelfStep.test.tsx new file mode 100644 index 0000000..265cfd2 --- /dev/null +++ b/tests/app/setup/steps/AudiobookshelfStep.test.tsx @@ -0,0 +1,83 @@ +/** + * Component: Setup Audiobookshelf Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React, { useState } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AudiobookshelfStep } from '@/app/setup/steps/AudiobookshelfStep'; + +const AudiobookshelfHarness = ({ + onNext, + onBack, + initialState, +}: { + onNext: () => void; + onBack: () => void; + initialState?: Partial>; +}) => { + const [state, setState] = useState({ + absUrl: 'http://abs.local', + absApiToken: 'token', + absLibraryId: '', + absTriggerScanAfterImport: false, + ...initialState, + }); + + return ( + setState((prev) => ({ ...prev, [field]: value }))} + onNext={onNext} + onBack={onBack} + /> + ); +}; + +describe('AudiobookshelfStep', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('requires library selection after successful test', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + libraries: [{ id: 'lib-1', name: 'Main', itemCount: 10 }], + }), + }); + vi.stubGlobal('fetch', fetchMock); + const onNext = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Test Connection' })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-abs', expect.any(Object)); + }); + + await screen.findByText('Connection successful!'); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(screen.getByText('Please select an audiobook library')).toBeInTheDocument(); + expect(onNext).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Test Connection' })); + await screen.findByText('Connection successful!'); + + const librarySelect = await screen.findByRole('combobox'); + fireEvent.change(librarySelect, { target: { value: 'lib-1' } }); + + await waitFor(() => { + expect(librarySelect).toHaveValue('lib-1'); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(onNext).toHaveBeenCalled(); + }); +}); diff --git a/tests/app/setup/steps/AuthMethodStep.test.tsx b/tests/app/setup/steps/AuthMethodStep.test.tsx new file mode 100644 index 0000000..984ad7b --- /dev/null +++ b/tests/app/setup/steps/AuthMethodStep.test.tsx @@ -0,0 +1,104 @@ +/** + * Component: Auth Method Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +describe('AuthMethodStep', () => { + it('highlights the selected auth method', async () => { + const { AuthMethodStep } = await import('@/app/setup/steps/AuthMethodStep'); + + const { rerender } = render( + + ); + + const oidcLabel = screen.getByRole('radio', { name: /OIDC Provider/i }).closest('label'); + const manualLabel = screen.getByRole('radio', { name: /Manual Registration/i }).closest('label'); + const bothLabel = screen.getByRole('radio', { name: /Both/i }).closest('label'); + + expect(oidcLabel).toHaveClass('border-blue-500'); + expect(manualLabel).toHaveClass('border-gray-200'); + expect(bothLabel).toHaveClass('border-gray-200'); + + rerender( + + ); + + expect(screen.getByRole('radio', { name: /Manual Registration/i }).closest('label')).toHaveClass('border-blue-500'); + + rerender( + + ); + + expect(screen.getByRole('radio', { name: /Both/i }).closest('label')).toHaveClass('border-blue-500'); + }); + + it('updates auth method and navigates', async () => { + const onChange = vi.fn(); + const onNext = vi.fn(); + const onBack = vi.fn(); + const { AuthMethodStep } = await import('@/app/setup/steps/AuthMethodStep'); + + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole('radio', { name: /Manual Registration/i })); + expect(onChange).toHaveBeenCalledWith('manual'); + + rerender( + + ); + + fireEvent.click(screen.getByRole('radio', { name: /OIDC Provider/i })); + expect(onChange).toHaveBeenCalledWith('oidc'); + + rerender( + + ); + + fireEvent.click(screen.getByRole('radio', { name: /Both/i })); + expect(onChange).toHaveBeenCalledWith('both'); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(onBack).toHaveBeenCalled(); + expect(onNext).toHaveBeenCalled(); + }); +}); diff --git a/tests/app/setup/steps/BackendSelectionStep.test.tsx b/tests/app/setup/steps/BackendSelectionStep.test.tsx new file mode 100644 index 0000000..f952d7e --- /dev/null +++ b/tests/app/setup/steps/BackendSelectionStep.test.tsx @@ -0,0 +1,73 @@ +/** + * Component: Backend Selection Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +describe('BackendSelectionStep', () => { + it('updates the audible region helper based on backend', async () => { + const { BackendSelectionStep } = await import('@/app/setup/steps/BackendSelectionStep'); + + const { rerender } = render( + + ); + + expect(screen.getByText(/configuration in Plex/i)).toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getByText(/configuration in Audiobookshelf/i)).toBeInTheDocument(); + }); + + it('updates backend selection and audible region', async () => { + const onChange = vi.fn(); + const onAudibleRegionChange = vi.fn(); + const onNext = vi.fn(); + const onBack = vi.fn(); + const { BackendSelectionStep } = await import('@/app/setup/steps/BackendSelectionStep'); + + render( + + ); + + fireEvent.click(screen.getByRole('radio', { name: /Audiobookshelf/i })); + expect(onChange).toHaveBeenCalledWith('audiobookshelf'); + + fireEvent.change(screen.getByLabelText('Audible Region'), { target: { value: 'uk' } }); + expect(onAudibleRegionChange).toHaveBeenCalledWith('uk'); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(onBack).toHaveBeenCalled(); + expect(onNext).toHaveBeenCalled(); + }); +}); diff --git a/tests/app/setup/steps/BookDateStep.test.tsx b/tests/app/setup/steps/BookDateStep.test.tsx new file mode 100644 index 0000000..b884e9d --- /dev/null +++ b/tests/app/setup/steps/BookDateStep.test.tsx @@ -0,0 +1,179 @@ +/** + * Component: Setup BookDate Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React, { useState } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { BookDateStep } from '@/app/setup/steps/BookDateStep'; + +const BookDateHarness = ({ + onNext, + onSkip, + onBack, + initialState, +}: { + onNext: () => void; + onSkip: () => void; + onBack: () => void; + initialState?: Partial>; +}) => { + const [state, setState] = useState({ + bookdateProvider: 'openai', + bookdateApiKey: '', + bookdateModel: '', + bookdateConfigured: false, + ...initialState, + }); + + return ( + setState((prev) => ({ ...prev, [field]: value }))} + onNext={onNext} + onSkip={onSkip} + onBack={onBack} + /> + ); +}; + +describe('BookDateStep', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('disables connection test when API key is missing', async () => { + render( + + ); + + expect(screen.getByRole('button', { name: /Test Connection/ })).toBeDisabled(); + }); + + it('fetches models and proceeds after successful test', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + models: [ + { id: 'model-1', name: 'Model One' }, + ], + }), + }); + vi.stubGlobal('fetch', fetchMock); + const onNext = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /Test Connection/ })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/bookdate/test-connection', expect.any(Object)); + }); + + expect(await screen.findByText('Select Model')).toBeInTheDocument(); + + const nextButton = screen.getByRole('button', { name: 'Next' }); + await waitFor(() => { + expect(nextButton).not.toBeDisabled(); + }); + fireEvent.click(nextButton); + expect(onNext).toHaveBeenCalled(); + }); + + it('shows an error when the connection test fails', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ error: 'Invalid API key' }), + }); + vi.stubGlobal('fetch', fetchMock); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /Test Connection/ })); + + await waitFor(() => { + expect(screen.getByText('Invalid API key')).toBeInTheDocument(); + }); + }); + + it('auto-selects the first model and shows the note', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + models: [ + { id: 'model-1', name: 'Model One' }, + { id: 'model-2', name: 'Model Two' }, + ], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /Test Connection/ })); + + await waitFor(() => { + expect(screen.getByText('Select Model')).toBeInTheDocument(); + }); + + const selects = screen.getAllByRole('combobox'); + const modelSelect = selects[1]; + expect(modelSelect).toHaveValue('model-1'); + expect(screen.getByText(/Library scope and custom prompt preferences/)).toBeInTheDocument(); + }); + + it('clears tested state and models when switching providers', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + models: [{ id: 'model-1', name: 'Model One' }], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /Test Connection/ })); + + await waitFor(() => { + expect(screen.getByText('Select Model')).toBeInTheDocument(); + }); + + const providerSelect = screen.getAllByRole('combobox')[0]; + fireEvent.change(providerSelect, { target: { value: 'claude' } }); + + expect(screen.queryByText('Select Model')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); + }); +}); diff --git a/tests/app/setup/steps/DownloadClientStep.test.tsx b/tests/app/setup/steps/DownloadClientStep.test.tsx new file mode 100644 index 0000000..7ad710a --- /dev/null +++ b/tests/app/setup/steps/DownloadClientStep.test.tsx @@ -0,0 +1,156 @@ +/** + * Component: Setup Download Client Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React, { useState } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DownloadClientStep } from '@/app/setup/steps/DownloadClientStep'; + +const DownloadClientHarness = ({ + onNext, + onBack, + initialState, +}: { + onNext: () => void; + onBack: () => void; + initialState?: Partial>; +}) => { + const [state, setState] = useState({ + downloadClient: 'qbittorrent' as const, + downloadClientUrl: 'https://qbittorrent.local', + downloadClientUsername: 'admin', + downloadClientPassword: 'secret', + disableSSLVerify: false, + remotePathMappingEnabled: false, + remotePath: '', + localPath: '', + ...initialState, + }); + + return ( + setState((prev) => ({ ...prev, [field]: value }))} + onNext={onNext} + onBack={onBack} + /> + ); +}; + +describe('DownloadClientStep', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('tests connection and enables navigation after success', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, version: '1.2.3' }), + }); + vi.stubGlobal('fetch', fetchMock); + const onNext = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Test Connection' })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object)); + }); + + expect(screen.getByText(/Connected successfully!/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(onNext).toHaveBeenCalled(); + }); + + it('shows remote path fields and toggles SSL verify', async () => { + render(); + + const sslToggle = screen.getByLabelText('Disable SSL Certificate Verification'); + fireEvent.click(sslToggle); + expect(sslToggle).toBeChecked(); + + const remoteToggle = screen.getByLabelText('Enable Remote Path Mapping'); + fireEvent.click(remoteToggle); + + expect(screen.getByPlaceholderText('/remote/mnt/d/done')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('/downloads')).toBeInTheDocument(); + }); + + it('switches to SABnzbd and shows API key field', async () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /SABnzbd/ })); + + expect(screen.getByText('API Key')).toBeInTheDocument(); + expect(screen.queryByText('Username')).toBeNull(); + }); + + it('blocks next when connection has not been tested', async () => { + const onNext = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(screen.getByText('Please test the connection before proceeding')).toBeInTheDocument(); + expect(onNext).not.toHaveBeenCalled(); + }); + + it('shows an error when the connection test fails', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ error: 'Bad credentials' }), + }); + vi.stubGlobal('fetch', fetchMock); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Test Connection' })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-download-client', expect.any(Object)); + }); + + expect(screen.getByText('Bad credentials')).toBeInTheDocument(); + }); + + it('disables test connection when SABnzbd fields are incomplete', async () => { + render( + + ); + + const testButton = screen.getByRole('button', { name: 'Test Connection' }); + expect(testButton).toBeDisabled(); + }); + + it('hides SSL toggle when using http URLs', async () => { + render( + + ); + + expect(screen.queryByLabelText('Disable SSL Certificate Verification')).toBeNull(); + }); +}); diff --git a/tests/app/setup/steps/FinalizeStep.test.tsx b/tests/app/setup/steps/FinalizeStep.test.tsx new file mode 100644 index 0000000..346de89 --- /dev/null +++ b/tests/app/setup/steps/FinalizeStep.test.tsx @@ -0,0 +1,138 @@ +/** + * Component: Finalize Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('FinalizeStep', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it('shows OIDC-only instructions and completes setup', async () => { + const onComplete = vi.fn(); + const onBack = vi.fn(); + const { FinalizeStep } = await import('@/app/setup/steps/FinalizeStep'); + + render( + + ); + + expect(screen.getByText('Setup Complete!')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + fireEvent.click(screen.getByRole('button', { name: 'Finish Setup' })); + + expect(onBack).toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalled(); + }); + + it('marks jobs as error when no access token is available', async () => { + const onComplete = vi.fn(); + const onBack = vi.fn(); + const { FinalizeStep } = await import('@/app/setup/steps/FinalizeStep'); + + render( + + ); + + await waitFor(() => { + expect(screen.getAllByText(/Authentication required/).length).toBeGreaterThan(0); + }); + }); + + it('runs initial jobs and enables completion on success', async () => { + vi.useFakeTimers(); + localStorage.setItem('accessToken', 'token'); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url === '/api/admin/jobs') { + return { + ok: true, + json: async () => ({ + jobs: [ + { id: 'job-1', type: 'audible_refresh' }, + { id: 'job-2', type: 'plex_library_scan' }, + ], + }), + }; + } + if (url === '/api/admin/jobs/job-1/trigger') { + return { ok: true, json: async () => ({ jobId: 'run-1' }) }; + } + if (url === '/api/admin/jobs/job-2/trigger') { + return { ok: true, json: async () => ({ jobId: 'run-2' }) }; + } + if (url === '/api/admin/job-status/run-1' || url === '/api/admin/job-status/run-2') { + return { ok: true, json: async () => ({ job: { status: 'completed' } }) }; + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const onComplete = vi.fn(); + const onBack = vi.fn(); + const { FinalizeStep } = await import('@/app/setup/steps/FinalizeStep'); + + render( + + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(screen.getAllByText('Completed successfully').length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: 'Finish Setup' })).toBeEnabled(); + }); + + it('marks missing job configuration as an error', async () => { + vi.useFakeTimers(); + localStorage.setItem('accessToken', 'token'); + + const fetchMock = vi.fn(async (input: RequestInfo) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url === '/api/admin/jobs') { + return { + ok: true, + json: async () => ({ + jobs: [ + { id: 'job-1', type: 'audible_refresh' }, + ], + }), + }; + } + if (url === '/api/admin/jobs/job-1/trigger') { + return { ok: true, json: async () => ({ jobId: 'run-1' }) }; + } + if (url === '/api/admin/job-status/run-1') { + return { ok: true, json: async () => ({ job: { status: 'completed' } }) }; + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + vi.stubGlobal('fetch', fetchMock); + + const { FinalizeStep } = await import('@/app/setup/steps/FinalizeStep'); + + render( + + ); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(screen.getAllByText(/Job configuration not found/).length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: 'Finish Setup' })).toBeEnabled(); + }); +}); diff --git a/tests/app/setup/steps/OIDCConfigStep.test.tsx b/tests/app/setup/steps/OIDCConfigStep.test.tsx new file mode 100644 index 0000000..3873669 --- /dev/null +++ b/tests/app/setup/steps/OIDCConfigStep.test.tsx @@ -0,0 +1,290 @@ +/** + * Component: Setup OIDC Config Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React, { useState } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { OIDCConfigStep } from '@/app/setup/steps/OIDCConfigStep'; + +const OIDCHarness = ({ + onNext, + onBack, + initialState, +}: { + onNext: () => void; + onBack: () => void; + initialState?: Partial>; +}) => { + const [state, setState] = useState({ + oidcProviderName: 'Auth', + oidcIssuerUrl: 'https://auth.example.com', + oidcClientId: 'client', + oidcClientSecret: 'secret', + oidcAccessControlMethod: 'open', + oidcAccessGroupClaim: '', + oidcAccessGroupValue: '', + oidcAllowedEmails: '', + oidcAllowedUsernames: '', + oidcAdminClaimEnabled: false, + oidcAdminClaimName: '', + oidcAdminClaimValue: '', + ...initialState, + }); + + return ( + setState((prev) => ({ ...prev, [field]: value }))} + onNext={onNext} + onBack={onBack} + /> + ); +}; + +describe('OIDCConfigStep', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('requires a successful test before proceeding', async () => { + const onNext = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(screen.getByText('Please test the OIDC configuration before proceeding')).toBeInTheDocument(); + expect(onNext).not.toHaveBeenCalled(); + }); + + it('tests connection and shows access control fields', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + vi.stubGlobal('fetch', fetchMock); + const onNext = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Test OIDC Configuration' })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-oidc', expect.any(Object)); + }); + + expect(screen.getByText('OIDC discovery successful! Provider configuration validated.')).toBeInTheDocument(); + + const accessControlSelect = screen.getByRole('combobox'); + fireEvent.change(accessControlSelect, { + target: { value: 'allowed_list' }, + }); + + expect(screen.getByPlaceholderText('user1@example.com, user2@example.com')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('john_doe, jane_smith')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Enable Admin Role Mapping')); + + expect(screen.getByPlaceholderText('groups')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('readmeabook-admin')).toBeInTheDocument(); + }); + + it('disables testing when required fields are missing', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: 'Test OIDC Configuration' })).toBeDisabled(); + }); + + it('shows error text when connection test fails', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: false, error: 'Invalid issuer' }), + }); + vi.stubGlobal('fetch', fetchMock); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Test OIDC Configuration' })); + + await waitFor(() => { + expect(screen.getByText('Invalid issuer')).toBeInTheDocument(); + }); + }); + + it('shows error text when connection test throws', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('Network down')); + vi.stubGlobal('fetch', fetchMock); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Test OIDC Configuration' })); + + await waitFor(() => { + expect(screen.getByText('Network down')).toBeInTheDocument(); + }); + }); + + it('updates access control helper text and fields per method', () => { + render(); + + const accessControlSelect = screen.getByRole('combobox'); + expect( + screen.getByText('Anyone who can authenticate with your OIDC provider will have access'), + ).toBeInTheDocument(); + + fireEvent.change(accessControlSelect, { + target: { value: 'group_claim' }, + }); + expect(screen.getByText('Only users with a specific group/claim can access')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('readmeabook-users')).toBeInTheDocument(); + + fireEvent.change(accessControlSelect, { + target: { value: 'allowed_list' }, + }); + expect(screen.getByText('Only explicitly allowed users can access')).toBeInTheDocument(); + + fireEvent.change(accessControlSelect, { + target: { value: 'admin_approval' }, + }); + expect( + screen.getByText('New users must be approved by an admin before access is granted'), + ).toBeInTheDocument(); + }); + + it('allows proceeding after a successful test', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + vi.stubGlobal('fetch', fetchMock); + const onNext = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Test OIDC Configuration' })); + + await waitFor(() => { + expect(screen.getByText('OIDC discovery successful! Provider configuration validated.')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(onNext).toHaveBeenCalled(); + }); + + it('updates provider fields and access control inputs', () => { + const onUpdate = vi.fn(); + + render( + , + ); + + fireEvent.change(screen.getByPlaceholderText('Authentik'), { + target: { value: 'Keycloak' }, + }); + fireEvent.change( + screen.getByPlaceholderText('https://auth.example.com/application/o/readmeabook/'), + { + target: { value: 'https://issuer.example' }, + }, + ); + fireEvent.change(screen.getByPlaceholderText('readmeabook'), { + target: { value: 'rmab-client' }, + }); + fireEvent.change(screen.getByPlaceholderText('Enter client secret'), { + target: { value: 'new-secret' }, + }); + + const groupInputs = screen.getAllByPlaceholderText('groups'); + fireEvent.change(groupInputs[0], { target: { value: 'roles' } }); + fireEvent.change(screen.getByPlaceholderText('readmeabook-users'), { + target: { value: 'rmab-users' }, + }); + fireEvent.change(groupInputs[1], { target: { value: 'admin-roles' } }); + fireEvent.change(screen.getByPlaceholderText('readmeabook-admin'), { + target: { value: 'rmab-admin' }, + }); + + expect(onUpdate).toHaveBeenCalledWith('oidcProviderName', 'Keycloak'); + expect(onUpdate).toHaveBeenCalledWith('oidcIssuerUrl', 'https://issuer.example'); + expect(onUpdate).toHaveBeenCalledWith('oidcClientId', 'rmab-client'); + expect(onUpdate).toHaveBeenCalledWith('oidcClientSecret', 'new-secret'); + expect(onUpdate).toHaveBeenCalledWith('oidcAccessGroupClaim', 'roles'); + expect(onUpdate).toHaveBeenCalledWith('oidcAccessGroupValue', 'rmab-users'); + expect(onUpdate).toHaveBeenCalledWith('oidcAdminClaimName', 'admin-roles'); + expect(onUpdate).toHaveBeenCalledWith('oidcAdminClaimValue', 'rmab-admin'); + }); + + it('updates allowed list fields and toggles admin mapping', () => { + const onUpdate = vi.fn(); + + render( + , + ); + + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'allowed_list' }, + }); + fireEvent.change(screen.getByPlaceholderText('user1@example.com, user2@example.com'), { + target: { value: 'reader@example.com' }, + }); + fireEvent.change(screen.getByPlaceholderText('john_doe, jane_smith'), { + target: { value: 'reader1' }, + }); + + fireEvent.click(screen.getByLabelText('Enable Admin Role Mapping')); + + expect(onUpdate).toHaveBeenCalledWith('oidcAccessControlMethod', 'allowed_list'); + expect(onUpdate).toHaveBeenCalledWith('oidcAllowedEmails', 'reader@example.com'); + expect(onUpdate).toHaveBeenCalledWith('oidcAllowedUsernames', 'reader1'); + expect(onUpdate).toHaveBeenCalledWith('oidcAdminClaimEnabled', true); + }); + + it('navigates back when Back is clicked', () => { + const onBack = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(onBack).toHaveBeenCalled(); + }); +}); diff --git a/tests/app/setup/steps/PathsStep.test.tsx b/tests/app/setup/steps/PathsStep.test.tsx new file mode 100644 index 0000000..e210016 --- /dev/null +++ b/tests/app/setup/steps/PathsStep.test.tsx @@ -0,0 +1,100 @@ +/** + * Component: Setup Paths Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React, { useState } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PathsStep } from '@/app/setup/steps/PathsStep'; + +const PathsHarness = ({ + onNext, + onBack, + initialState, +}: { + onNext: () => void; + onBack: () => void; + initialState?: Partial>; +}) => { + const [state, setState] = useState({ + downloadDir: '/downloads', + mediaDir: '/media/audiobooks', + metadataTaggingEnabled: true, + chapterMergingEnabled: false, + ...initialState, + }); + + return ( + setState((prev) => ({ ...prev, [field]: value }))} + onNext={onNext} + onBack={onBack} + /> + ); +}; + +describe('PathsStep', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('validates paths and allows navigation on success', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + message: 'Directories are ready', + downloadDirValid: true, + mediaDirValid: true, + }), + }); + vi.stubGlobal('fetch', fetchMock); + const onNext = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Validate Paths' })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-paths', expect.any(Object)); + }); + + expect(screen.getByText('Directories are ready')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(onNext).toHaveBeenCalled(); + }); + + it('requires validation before proceeding', async () => { + const onNext = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(screen.getByText('Please validate the paths before proceeding')).toBeInTheDocument(); + expect(onNext).not.toHaveBeenCalled(); + }); + + it('toggles metadata and chapter merge settings', async () => { + render( + + ); + + const metadataToggle = screen.getByLabelText('Auto-tag audio files with metadata'); + const chapterToggle = screen.getByLabelText('Auto-merge chapters to M4B'); + + fireEvent.click(metadataToggle); + fireEvent.click(chapterToggle); + + expect(metadataToggle).toBeChecked(); + expect(chapterToggle).toBeChecked(); + }); +}); diff --git a/tests/app/setup/steps/PlexStep.test.tsx b/tests/app/setup/steps/PlexStep.test.tsx new file mode 100644 index 0000000..9eb6d48 --- /dev/null +++ b/tests/app/setup/steps/PlexStep.test.tsx @@ -0,0 +1,84 @@ +/** + * Component: Setup Plex Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React, { useState } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PlexStep } from '@/app/setup/steps/PlexStep'; + +const PlexHarness = ({ + onNext, + onBack, + initialState, +}: { + onNext: () => void; + onBack: () => void; + initialState?: Partial>; +}) => { + const [state, setState] = useState({ + plexUrl: 'http://plex.local', + plexToken: 'token', + plexLibraryId: '', + plexTriggerScanAfterImport: false, + ...initialState, + }); + + return ( + setState((prev) => ({ ...prev, [field]: value }))} + onNext={onNext} + onBack={onBack} + /> + ); +}; + +describe('PlexStep', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('requires library selection after successful test', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + serverName: 'Plex', + libraries: [{ id: 'lib-1', title: 'Audiobooks', type: 'artist' }], + }), + }); + vi.stubGlobal('fetch', fetchMock); + const onNext = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Test Connection' })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-plex', expect.any(Object)); + }); + + await screen.findByText(/Connected to Plex/i); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(screen.getByText('Please select an audiobook library')).toBeInTheDocument(); + expect(onNext).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Test Connection' })); + await screen.findByText(/Connected to Plex/i); + + const librarySelect = await screen.findByRole('combobox'); + fireEvent.change(librarySelect, { target: { value: 'lib-1' } }); + + await waitFor(() => { + expect(librarySelect).toHaveValue('lib-1'); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(onNext).toHaveBeenCalled(); + }); +}); diff --git a/tests/app/setup/steps/ProwlarrStep.test.tsx b/tests/app/setup/steps/ProwlarrStep.test.tsx new file mode 100644 index 0000000..c85a834 --- /dev/null +++ b/tests/app/setup/steps/ProwlarrStep.test.tsx @@ -0,0 +1,76 @@ +/** + * Component: Setup Prowlarr Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +const indexersMock = [ + { + id: 1, + name: 'Indexer', + priority: 10, + seedingTimeMinutes: 0, + rssEnabled: true, + categories: [], + }, +]; + +vi.mock('@/components/admin/indexers/IndexerManagement', () => ({ + IndexerManagement: ({ onIndexersChange }: { onIndexersChange: (indexers: any[]) => void }) => ( + + ), +})); + +describe('ProwlarrStep', () => { + it('shows validation errors when required fields are missing', async () => { + const { ProwlarrStep } = await import('@/app/setup/steps/ProwlarrStep'); + const onNext = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(screen.getByText('Please enter Prowlarr URL and API key')).toBeInTheDocument(); + expect(onNext).not.toHaveBeenCalled(); + }); + + it('updates indexers and proceeds when valid', async () => { + const { ProwlarrStep } = await import('@/app/setup/steps/ProwlarrStep'); + const onUpdate = vi.fn(); + const onNext = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Set Indexers' })); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith('prowlarrIndexers', indexersMock); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(onNext).toHaveBeenCalled(); + }); +}); diff --git a/tests/app/setup/steps/RegistrationSettingsStep.test.tsx b/tests/app/setup/steps/RegistrationSettingsStep.test.tsx new file mode 100644 index 0000000..e5cdb66 --- /dev/null +++ b/tests/app/setup/steps/RegistrationSettingsStep.test.tsx @@ -0,0 +1,54 @@ +/** + * Component: Registration Settings Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React, { useState } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { RegistrationSettingsStep } from '@/app/setup/steps/RegistrationSettingsStep'; + +const RegistrationHarness = ({ + onNext, + onBack, + initialValue = false, +}: { + onNext: () => void; + onBack: () => void; + initialValue?: boolean; +}) => { + const [requireAdminApproval, setRequireAdminApproval] = useState(initialValue); + + return ( + setRequireAdminApproval(value)} + onNext={onNext} + onBack={onBack} + /> + ); +}; + +describe('RegistrationSettingsStep', () => { + it('toggles admin approval and navigates', async () => { + const onNext = vi.fn(); + const onBack = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + + expect(onBack).toHaveBeenCalled(); + expect(onNext).toHaveBeenCalled(); + + expect(screen.getByText('Auto-Approval Enabled')).toBeInTheDocument(); + const buttons = screen.getAllByRole('button'); + const toggleButton = buttons.find((button) => !['Back', 'Next'].includes(button.textContent || '')); + expect(toggleButton).toBeDefined(); + fireEvent.click(toggleButton as HTMLButtonElement); + expect(screen.getByText('Admin Approval Workflow')).toBeInTheDocument(); + }); +}); diff --git a/tests/app/setup/steps/ReviewStep.test.tsx b/tests/app/setup/steps/ReviewStep.test.tsx new file mode 100644 index 0000000..9994929 --- /dev/null +++ b/tests/app/setup/steps/ReviewStep.test.tsx @@ -0,0 +1,72 @@ +/** + * Component: Setup Review Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ReviewStep } from '@/app/setup/steps/ReviewStep'; + +const baseConfig = { + backendMode: 'plex' as const, + plexUrl: 'http://plex.local', + plexLibraryId: 'plex-lib', + absUrl: 'http://abs.local', + absLibraryId: 'abs-lib', + authMethod: 'oidc' as const, + oidcProviderName: 'Auth', + adminUsername: 'admin', + prowlarrUrl: 'http://prowlarr.local', + downloadClient: 'qbittorrent' as const, + downloadClientUrl: 'http://qb.local', + downloadDir: '/downloads', + mediaDir: '/media', + bookdateConfigured: true, + bookdateProvider: 'openai', + bookdateModel: 'model-1', +}; + +describe('ReviewStep', () => { + it('renders Plex configuration and triggers actions', async () => { + const onComplete = vi.fn(); + const onBack = vi.fn(); + + render( + + ); + + expect(screen.getByText('Plex Media Server')).toBeInTheDocument(); + expect(screen.getByText('BookDate AI Recommendations')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + fireEvent.click(screen.getByRole('button', { name: 'Complete Setup' })); + + expect(onBack).toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalled(); + }); + + it('renders Audiobookshelf config and error state', async () => { + render( + + ); + + expect(screen.getByText('Audiobookshelf')).toBeInTheDocument(); + expect(screen.getByText('OIDC + Manual Registration')).toBeInTheDocument(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); +}); diff --git a/tests/app/setup/steps/WelcomeStep.test.tsx b/tests/app/setup/steps/WelcomeStep.test.tsx new file mode 100644 index 0000000..60dc124 --- /dev/null +++ b/tests/app/setup/steps/WelcomeStep.test.tsx @@ -0,0 +1,22 @@ +/** + * Component: Setup Welcome Step Tests + * Documentation: documentation/setup-wizard.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +describe('WelcomeStep', () => { + it('calls onNext when Get Started is clicked', async () => { + const onNext = vi.fn(); + const { WelcomeStep } = await import('@/app/setup/steps/WelcomeStep'); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /Get Started/i })); + expect(onNext).toHaveBeenCalled(); + }); +}); diff --git a/tests/components/admin/FlagConfigRow.test.tsx b/tests/components/admin/FlagConfigRow.test.tsx new file mode 100644 index 0000000..85dd03c --- /dev/null +++ b/tests/components/admin/FlagConfigRow.test.tsx @@ -0,0 +1,41 @@ +/** + * Component: Flag Config Row Tests + * Documentation: documentation/phase3/ranking-algorithm.md + */ + +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { FlagConfigRow } from '@/components/admin/FlagConfigRow'; +import type { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm'; + +describe('FlagConfigRow', () => { + it('updates name and modifier values and allows removal', () => { + const onChange = vi.fn(); + const onRemove = vi.fn(); + const config: IndexerFlagConfig = { name: 'Freeleech', modifier: 20 }; + + render(); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Internal' } }); + fireEvent.change(screen.getByRole('slider'), { target: { value: '-15' } }); + fireEvent.click(screen.getByTitle('Remove flag rule')); + + expect(onChange).toHaveBeenCalledWith({ name: 'Internal', modifier: 20 }); + expect(onChange).toHaveBeenCalledWith({ name: 'Freeleech', modifier: -15 }); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('shows disqualification warning for large negative modifiers', () => { + render( + + ); + + expect(screen.getByText(/Would disqualify/i)).toBeInTheDocument(); + }); +}); diff --git a/tests/components/admin/indexers/AvailableIndexerRow.test.tsx b/tests/components/admin/indexers/AvailableIndexerRow.test.tsx new file mode 100644 index 0000000..61a26d1 --- /dev/null +++ b/tests/components/admin/indexers/AvailableIndexerRow.test.tsx @@ -0,0 +1,38 @@ +/** + * Component: Available Indexer Row Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { AvailableIndexerRow } from '@/components/admin/indexers/AvailableIndexerRow'; + +describe('AvailableIndexerRow', () => { + const indexer = { + id: 1, + name: 'Test Indexer', + protocol: 'torrent', + supportsRss: true, + }; + + it('renders an add button when the indexer is not added', () => { + const onAdd = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + + expect(onAdd).toHaveBeenCalledTimes(1); + expect(screen.getByText('Test Indexer')).toBeInTheDocument(); + expect(screen.getByText('torrent')).toBeInTheDocument(); + }); + + it('renders the added state when already configured', () => { + render(); + + expect(screen.getByText('Added')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add' })).toBeNull(); + }); +}); diff --git a/tests/components/admin/indexers/CategoryTreeView.test.tsx b/tests/components/admin/indexers/CategoryTreeView.test.tsx new file mode 100644 index 0000000..cffeda4 --- /dev/null +++ b/tests/components/admin/indexers/CategoryTreeView.test.tsx @@ -0,0 +1,67 @@ +/** + * Component: Category Tree View Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { CategoryTreeView } from '@/components/admin/indexers/CategoryTreeView'; +import { getChildIds } from '@/lib/utils/torrent-categories'; + +describe('CategoryTreeView', () => { + it('selects parent and children when the parent is toggled', () => { + const onChange = vi.fn(); + + render(); + + const audioLabel = screen.getByText('Audio'); + const audioRow = audioLabel.closest('div')?.parentElement; + if (!audioRow) { + throw new Error('Audio parent row not found'); + } + + fireEvent.click(within(audioRow).getByRole('switch')); + + const audioChildren = getChildIds(3000); + expect(onChange).toHaveBeenCalledWith( + expect.arrayContaining([3000, ...audioChildren]) + ); + }); + + it('toggles a child category on and off', () => { + const onChange = vi.fn(); + + render(); + + const audiobookLabel = screen.getByText('Audiobook'); + const audiobookRow = audiobookLabel.closest('div')?.parentElement; + if (!audiobookRow) { + throw new Error('Audiobook row not found'); + } + + fireEvent.click(within(audiobookRow).getByRole('switch')); + + expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([3030])); + }); + + it('disables child toggles when all children are selected', () => { + const audioChildren = getChildIds(3000); + + render( + + ); + + const audiobookLabel = screen.getByText('Audiobook'); + const audiobookRow = audiobookLabel.closest('div')?.parentElement; + if (!audiobookRow) { + throw new Error('Audiobook row not found'); + } + + expect(within(audiobookRow).getByRole('switch')).toBeDisabled(); + }); +}); diff --git a/tests/components/admin/indexers/DeleteConfirmModal.test.tsx b/tests/components/admin/indexers/DeleteConfirmModal.test.tsx new file mode 100644 index 0000000..88924d2 --- /dev/null +++ b/tests/components/admin/indexers/DeleteConfirmModal.test.tsx @@ -0,0 +1,48 @@ +/** + * Component: Delete Confirm Modal Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { DeleteConfirmModal } from '@/components/admin/indexers/DeleteConfirmModal'; + +describe('DeleteConfirmModal', () => { + it('confirms removal and closes the modal', () => { + const onClose = vi.fn(); + const onConfirm = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Remove Indexer' })); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('closes without confirming when canceled', () => { + const onClose = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/components/admin/indexers/IndexerCard.test.tsx b/tests/components/admin/indexers/IndexerCard.test.tsx new file mode 100644 index 0000000..1d4f3e8 --- /dev/null +++ b/tests/components/admin/indexers/IndexerCard.test.tsx @@ -0,0 +1,34 @@ +/** + * Component: Indexer Card Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { IndexerCard } from '@/components/admin/indexers/IndexerCard'; + +describe('IndexerCard', () => { + it('renders indexer info and triggers edit/delete actions', () => { + const onEdit = vi.fn(); + const onDelete = vi.fn(); + + render( + + ); + + expect(screen.getByText('IndexerTwo')).toBeInTheDocument(); + expect(screen.getByText('usenet')).toBeInTheDocument(); + + fireEvent.click(screen.getByTitle('Edit indexer')); + fireEvent.click(screen.getByTitle('Delete indexer')); + + expect(onEdit).toHaveBeenCalledTimes(1); + expect(onDelete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/components/admin/indexers/IndexerConfigModal.test.tsx b/tests/components/admin/indexers/IndexerConfigModal.test.tsx new file mode 100644 index 0000000..8487fd5 --- /dev/null +++ b/tests/components/admin/indexers/IndexerConfigModal.test.tsx @@ -0,0 +1,107 @@ +/** + * Component: Indexer Config Modal Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { IndexerConfigModal } from '@/components/admin/indexers/IndexerConfigModal'; + +describe('IndexerConfigModal', () => { + it('clamps numeric inputs and saves configuration', () => { + const onSave = vi.fn(); + const onClose = vi.fn(); + + render( + + ); + + const [priorityInput, seedingInput] = screen.getAllByRole('spinbutton'); + + fireEvent.change(priorityInput, { target: { value: '99' } }); + expect(priorityInput).toHaveValue(25); + + fireEvent.change(seedingInput, { target: { value: '-5' } }); + expect(seedingInput).toHaveValue(0); + + const rssToggle = screen.getByRole('checkbox'); + fireEvent.click(rssToggle); + + fireEvent.click(screen.getByRole('button', { name: 'Add Indexer' })); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + id: 1, + name: 'Prowlarr', + priority: 25, + seedingTimeMinutes: 0, + rssEnabled: false, + categories: expect.arrayContaining([3030]), + }) + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('validates that at least one category is selected', () => { + const onSave = vi.fn(); + + render( + + ); + + const audiobookLabel = screen.getByText('Audiobook'); + const audiobookRow = audiobookLabel.closest('div')?.parentElement; + if (!audiobookRow) { + throw new Error('Audiobook row not found'); + } + + fireEvent.click(within(audiobookRow).getByRole('switch')); + fireEvent.click(screen.getByRole('button', { name: 'Add Indexer' })); + + expect(screen.getByText('At least one category must be selected')).toBeInTheDocument(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('forces RSS to false when the indexer does not support RSS', () => { + const onSave = vi.fn(); + const onClose = vi.fn(); + + render( + + ); + + const rssToggle = screen.getByRole('checkbox'); + expect(rssToggle).toBeDisabled(); + + fireEvent.click(screen.getByRole('button', { name: 'Add Indexer' })); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + id: 3, + name: 'NoRSS', + rssEnabled: false, + }) + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/components/admin/indexers/IndexerManagement.test.tsx b/tests/components/admin/indexers/IndexerManagement.test.tsx new file mode 100644 index 0000000..b7540f5 --- /dev/null +++ b/tests/components/admin/indexers/IndexerManagement.test.tsx @@ -0,0 +1,166 @@ +/** + * Component: Indexer Management Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement'; + +const fetchWithAuthMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/utils/api', () => ({ + fetchWithAuth: fetchWithAuthMock, +})); + +vi.mock('@/components/admin/indexers/IndexerConfigModal', () => ({ + IndexerConfigModal: ({ isOpen, mode, indexer, initialConfig, onSave, onClose }: any) => { + if (!isOpen) return null; + const priority = initialConfig?.priority ? initialConfig.priority + 1 : 10; + return ( +
+
{mode}
+ + +
+ ); + }, +})); + +describe('IndexerManagement', () => { + const emptyIndexers: any[] = []; + + afterEach(() => { + vi.unstubAllGlobals(); + fetchWithAuthMock.mockReset(); + }); + + it('fetches indexers in wizard mode and adds a configuration', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + indexers: [ + { id: 1, name: 'IndexerA', protocol: 'torrent', supportsRss: true }, + ], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const onIndexersChange = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Fetch Indexers' })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-prowlarr', expect.any(Object)); + }); + + expect(fetchWithAuthMock).not.toHaveBeenCalled(); + expect(screen.getByText('IndexerA')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + const lastCall = onIndexersChange.mock.calls.at(-1)?.[0] as any[] | undefined; + expect(lastCall).toHaveLength(1); + expect(lastCall?.[0]).toMatchObject({ id: 1, name: 'IndexerA' }); + }); + + expect(screen.getByText('Configured Indexers (1)')).toBeInTheDocument(); + }); + + it('uses authenticated fetch in settings mode', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + fetchWithAuthMock.mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + indexers: [ + { id: 2, name: 'IndexerB', protocol: 'torrent', supportsRss: false }, + ], + }), + }); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Fetch Indexers' })); + + await waitFor(() => { + expect(fetchWithAuthMock).toHaveBeenCalledWith( + '/api/admin/settings/test-prowlarr', + expect.objectContaining({ method: 'POST' }) + ); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(screen.getByText('IndexerB')).toBeInTheDocument(); + }); + + it('removes a configured indexer after confirmation', async () => { + const onIndexersChange = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByTitle('Delete indexer')); + fireEvent.click(screen.getByRole('button', { name: 'Remove Indexer' })); + + await waitFor(() => { + const lastCall = onIndexersChange.mock.calls.at(-1)?.[0] as any[] | undefined; + expect(lastCall).toHaveLength(0); + }); + + expect(screen.getByText('Configured Indexers (0)')).toBeInTheDocument(); + }); +}); diff --git a/tests/components/audiobooks/AudiobookCard.test.tsx b/tests/components/audiobooks/AudiobookCard.test.tsx new file mode 100644 index 0000000..be881fe --- /dev/null +++ b/tests/components/audiobooks/AudiobookCard.test.tsx @@ -0,0 +1,178 @@ +/** + * Component: Audiobook Card Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const createRequestMock = vi.hoisted(() => vi.fn()); +const authState = { + user: null as null | { id: string; username: string }, +}; + +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => authState, +})); + +vi.mock('@/lib/hooks/useRequests', () => ({ + useCreateRequest: () => ({ createRequest: createRequestMock, isLoading: false }), +})); + +vi.mock('@/components/audiobooks/AudiobookDetailsModal', () => ({ + AudiobookDetailsModal: ({ isOpen }: { isOpen: boolean }) => ( +
+ ), +})); + +vi.mock('next/image', () => ({ + __esModule: true, + default: (props: any) => , +})); + +const baseAudiobook = { + asin: 'asin-1', + title: 'Test Book', + author: 'Author', +}; + +describe('AudiobookCard', () => { + beforeEach(() => { + authState.user = null; + createRequestMock.mockReset(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('disables requests when no user is logged in', async () => { + const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard'); + + render(); + + const requestButton = screen.getByRole('button', { name: 'Login to Request' }); + expect(requestButton).toBeDisabled(); + expect(createRequestMock).not.toHaveBeenCalled(); + }); + + it('creates a request and shows a success toast', async () => { + authState.user = { id: 'user-1', username: 'user' }; + createRequestMock.mockResolvedValueOnce(undefined); + + const onRequestSuccess = vi.fn(); + const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard'); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Request' })); + + const requestPromise = createRequestMock.mock.results[0]?.value; + await act(async () => { + await requestPromise; + }); + + expect(createRequestMock).toHaveBeenCalledWith(baseAudiobook); + expect(onRequestSuccess).toHaveBeenCalled(); + + expect(screen.getByText(/Request created successfully/)).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(3000); + }); + expect(screen.queryByText(/Request created successfully/)).toBeNull(); + }); + + it('shows in-library state when available', async () => { + const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard'); + + render(); + + expect(screen.getByText('In Your Library')).toBeInTheDocument(); + }); + + it('opens the details modal when the title is clicked', async () => { + const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard'); + + render(); + + expect(screen.getByTestId('details-modal')).toHaveAttribute('data-open', 'false'); + + fireEvent.click(screen.getByText('Test Book')); + + expect(screen.getByTestId('details-modal')).toHaveAttribute('data-open', 'true'); + }); + + it('shows processing state for downloaded requests', async () => { + const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard'); + + render( + + ); + + const button = screen.getByRole('button', { name: 'Processing...' }); + expect(button).toBeDisabled(); + }); + + it('shows pending approval status with requester name', async () => { + const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard'); + + render( + + ); + + expect(screen.getByRole('button', { name: /Pending Approval \(alice\)/ })).toBeDisabled(); + }); + + it('shows a denied request state', async () => { + const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard'); + + render( + + ); + + expect(screen.getByRole('button', { name: 'Request Denied' })).toBeDisabled(); + }); + + it('shows an error when a request fails', async () => { + authState.user = { id: 'user-1', username: 'user' }; + createRequestMock.mockRejectedValueOnce(new Error('Request failed')); + + const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard'); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Request' })); + + const requestPromise = createRequestMock.mock.results[0]?.value; + await act(async () => { + try { + await requestPromise; + } catch { + // Expected for this test. + } + }); + + expect(screen.getByText('Request failed')).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(5000); + }); + expect(screen.queryByText('Request failed')).toBeNull(); + }); +}); diff --git a/tests/components/audiobooks/AudiobookDetailsModal.test.tsx b/tests/components/audiobooks/AudiobookDetailsModal.test.tsx new file mode 100644 index 0000000..2b05f5f --- /dev/null +++ b/tests/components/audiobooks/AudiobookDetailsModal.test.tsx @@ -0,0 +1,291 @@ +/** + * Component: Audiobook Details Modal Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const useAuthMock = vi.hoisted(() => vi.fn()); +const useAudiobookDetailsMock = vi.hoisted(() => vi.fn()); +const createRequestMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => useAuthMock(), +})); + +vi.mock('@/lib/hooks/useAudiobooks', () => ({ + useAudiobookDetails: (asin: string | null) => useAudiobookDetailsMock(asin), +})); + +vi.mock('@/lib/hooks/useRequests', () => ({ + useCreateRequest: () => ({ createRequest: createRequestMock, isLoading: false }), +})); + +vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({ + InteractiveTorrentSearchModal: ({ isOpen }: { isOpen: boolean }) => ( +
+ ), +})); + +vi.mock('next/image', () => ({ + __esModule: true, + default: (props: any) => , +})); + +const audiobookDetails = { + asin: 'ASIN123', + title: 'Detail Book', + author: 'Detail Author', + description: 'Summary', + rating: 4.2, + durationMinutes: 320, + releaseDate: '2023-01-01', + genres: ['Fantasy'], +}; + +describe('AudiobookDetailsModal', () => { + beforeEach(() => { + useAuthMock.mockReturnValue({ user: { id: 'user-1', username: 'user' } }); + useAudiobookDetailsMock.mockReturnValue({ + audiobook: audiobookDetails, + isLoading: false, + error: null, + }); + createRequestMock.mockReset(); + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders audiobook details and closes when requested', async () => { + const onClose = vi.fn(); + const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal'); + + render( + + ); + + await act(async () => {}); + expect(screen.getByText('Detail Book')).toBeInTheDocument(); + expect(document.body.style.overflow).toBe('hidden'); + + fireEvent.click(screen.getByRole('button', { name: 'Close modal' })); + expect(onClose).toHaveBeenCalled(); + }); + + it('creates requests and auto-closes after success', async () => { + vi.useFakeTimers(); + createRequestMock.mockResolvedValueOnce(undefined); + const onClose = vi.fn(); + const onRequestSuccess = vi.fn(); + const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal'); + + render( + + ); + + await act(async () => {}); + const requestButton = screen.getByRole('button', { name: 'Request Audiobook' }); + fireEvent.click(requestButton); + + const requestPromise = createRequestMock.mock.results[0]?.value; + await act(async () => { + await requestPromise; + }); + + expect(onRequestSuccess).toHaveBeenCalled(); + expect(screen.getByText(/Request created successfully/)).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(2000); + }); + + expect(onClose).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('copies the ASIN to the clipboard', async () => { + const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal'); + + render( + + ); + + await act(async () => {}); + const asinButton = screen.getByText('ASIN123'); + await act(async () => { + fireEvent.click(asinButton.closest('button') as HTMLButtonElement); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('ASIN123'); + }); + + it('shows an error state when details fail to load', async () => { + useAudiobookDetailsMock.mockReturnValue({ + audiobook: null, + isLoading: false, + error: 'boom', + }); + const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal'); + + render( + + ); + + await act(async () => {}); + expect(screen.getByText('Failed to load audiobook details')).toBeInTheDocument(); + }); + + it('shows availability state and hides interactive search when available', async () => { + const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal'); + + render( + + ); + + await act(async () => {}); + expect(screen.getByText('Available in Your Library')).toBeInTheDocument(); + expect(screen.queryByLabelText('Interactive Search')).toBeNull(); + }); + + it('shows pending approval status with requester name', async () => { + const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal'); + + render( + + ); + + await act(async () => {}); + expect(screen.getByRole('button', { name: /Pending Approval \(alice\)/ })).toBeDisabled(); + }); + + it('shows a denied request state', async () => { + const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal'); + + render( + + ); + + await act(async () => {}); + expect(screen.getByRole('button', { name: 'Request Denied' })).toBeDisabled(); + }); + + it('shows Not Found when rating is missing', async () => { + useAudiobookDetailsMock.mockReturnValue({ + audiobook: { ...audiobookDetails, rating: 0 }, + isLoading: false, + error: null, + }); + const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal'); + + render( + + ); + + await act(async () => {}); + expect(screen.getByText('Not Found')).toBeInTheDocument(); + }); + + it('opens interactive search when requested', async () => { + const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal'); + + render( + + ); + + await act(async () => {}); + + expect(screen.queryByTestId('interactive-modal')).toBeNull(); + + fireEvent.click(screen.getByLabelText('Interactive Search')); + + expect(screen.getByTestId('interactive-modal')).toHaveAttribute('data-open', 'true'); + }); + + it('shows request error and clears it after timeout', async () => { + vi.useFakeTimers(); + createRequestMock.mockRejectedValueOnce(new Error('Request failed')); + const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal'); + + render( + + ); + + await act(async () => {}); + fireEvent.click(screen.getByRole('button', { name: 'Request Audiobook' })); + + const requestPromise = createRequestMock.mock.results[0]?.value; + await act(async () => { + try { + await requestPromise; + } catch { + // Expected for this test. + } + }); + + expect(screen.getByText('Request failed')).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(5000); + }); + + expect(screen.queryByText('Request failed')).toBeNull(); + }); +}); diff --git a/tests/components/audiobooks/AudiobookGrid.test.tsx b/tests/components/audiobooks/AudiobookGrid.test.tsx new file mode 100644 index 0000000..217ff01 --- /dev/null +++ b/tests/components/audiobooks/AudiobookGrid.test.tsx @@ -0,0 +1,55 @@ +/** + * Component: Audiobook Grid Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import path from 'path'; +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockAudiobookCard = () => { + vi.doMock(path.resolve('src/components/audiobooks/AudiobookCard.tsx'), () => ({ + AudiobookCard: ({ audiobook }: { audiobook: any }) => ( +
{audiobook.asin}
+ ), + })); +}; + +describe('AudiobookGrid', () => { + beforeEach(() => { + vi.resetModules(); + mockAudiobookCard(); + }); + + it('renders skeleton cards when loading', async () => { + const { AudiobookGrid } = await import('@/components/audiobooks/AudiobookGrid'); + + const { container } = render(); + + expect(container.querySelectorAll('.animate-pulse')).toHaveLength(8); + }); + + it('shows the empty message when there are no results', async () => { + const { AudiobookGrid } = await import('@/components/audiobooks/AudiobookGrid'); + + render(); + + expect(screen.getByText('Nothing found')).toBeInTheDocument(); + }); + + it('applies grid classes based on card size', async () => { + const { AudiobookGrid } = await import('@/components/audiobooks/AudiobookGrid'); + + const { container } = render( + + ); + + expect(container.querySelector('div')?.className).toContain('grid-cols-1'); + }); +}); diff --git a/tests/components/auth/ProtectedRoute.test.tsx b/tests/components/auth/ProtectedRoute.test.tsx new file mode 100644 index 0000000..65229f8 --- /dev/null +++ b/tests/components/auth/ProtectedRoute.test.tsx @@ -0,0 +1,90 @@ +/** + * Component: Protected Route Component Tests + * Documentation: documentation/frontend/routing-auth.md + */ + +// @vitest-environment jsdom + +import { beforeAll, describe, expect, it } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../../helpers/render'; +import { routerMock } from '../../helpers/mock-next-navigation'; + +describe('ProtectedRoute', () => { + let ProtectedRoute: typeof import('@/components/auth/ProtectedRoute').ProtectedRoute; + + beforeAll(async () => { + ({ ProtectedRoute } = await import('@/components/auth/ProtectedRoute')); + }); + + it('shows loading state while auth is initializing', async () => { + renderWithProviders( + +
Protected Content
+
, + { auth: { isLoading: true } } + ); + + expect(screen.getByText(/Loading/i)).toBeInTheDocument(); + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument(); + }); + + it('redirects unauthenticated users to login with return URL', async () => { + renderWithProviders( + +
Protected Content
+
, + { auth: { user: null, isLoading: false }, pathname: '/requests' } + ); + + await waitFor(() => { + expect(routerMock.push).toHaveBeenCalledWith('/login?redirect=%2Frequests'); + }); + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument(); + }); + + it('redirects non-admin users when admin access is required', async () => { + renderWithProviders( + +
Admin Content
+
, + { + auth: { + user: { + id: 'user-1', + plexId: 'plex-1', + username: 'reader', + role: 'user', + }, + isLoading: false, + }, + } + ); + + await waitFor(() => { + expect(routerMock.push).toHaveBeenCalledWith('/'); + }); + expect(screen.queryByText('Admin Content')).not.toBeInTheDocument(); + }); + + it('renders children for authenticated admins', async () => { + renderWithProviders( + +
Admin Content
+
, + { + auth: { + user: { + id: 'admin-1', + plexId: 'plex-1', + username: 'admin', + role: 'admin', + }, + isLoading: false, + }, + } + ); + + expect(screen.getByText('Admin Content')).toBeInTheDocument(); + }); +}); diff --git a/tests/components/bookdate/BookPickerModal.test.tsx b/tests/components/bookdate/BookPickerModal.test.tsx new file mode 100644 index 0000000..9105a30 --- /dev/null +++ b/tests/components/bookdate/BookPickerModal.test.tsx @@ -0,0 +1,158 @@ +/** + * Component: BookDate Book Picker Modal Tests + * Documentation: documentation/features/bookdate.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const books = [ + { id: 'book-1', title: 'First Book', author: 'Author One', coverUrl: null }, + { id: 'book-2', title: 'Second Book', author: 'Author Two', coverUrl: null }, +]; + +describe('BookPickerModal', () => { + afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it('loads books and confirms the selection', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ books }), + }); + vi.stubGlobal('fetch', fetchMock); + localStorage.setItem('accessToken', 'token-123'); + const onConfirm = vi.fn(); + const onClose = vi.fn(); + const { BookPickerModal } = await import('@/components/bookdate/BookPickerModal'); + + render( + + ); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/bookdate/library', { + headers: { Authorization: 'Bearer token-123' }, + }); + }); + + const firstBookButton = await screen.findByRole('button', { name: /First Book/ }); + fireEvent.click(firstBookButton); + + fireEvent.click(screen.getByRole('button', { name: /Confirm Selection/ })); + + expect(onConfirm).toHaveBeenCalledWith(['book-1']); + expect(onClose).toHaveBeenCalled(); + }); + + it('disables additional selections once the max is reached', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ books }), + }); + vi.stubGlobal('fetch', fetchMock); + localStorage.setItem('accessToken', 'token-456'); + const { BookPickerModal } = await import('@/components/bookdate/BookPickerModal'); + + render( + + ); + + const firstBookButton = await screen.findByRole('button', { name: /First Book/ }); + fireEvent.click(firstBookButton); + + expect(screen.getByText(/Maximum reached/)).toBeInTheDocument(); + + const secondBookButton = screen.getByRole('button', { name: /Second Book/ }); + expect(secondBookButton).toBeDisabled(); + }); + + it('shows an error when loading books fails', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({}), + }); + vi.stubGlobal('fetch', fetchMock); + const { BookPickerModal } = await import('@/components/bookdate/BookPickerModal'); + + render( + + ); + + expect(await screen.findByText('Failed to load library books')).toBeInTheDocument(); + }); + + it('shows an empty search state when no books match', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ books }), + }); + vi.stubGlobal('fetch', fetchMock); + const { BookPickerModal } = await import('@/components/bookdate/BookPickerModal'); + + render( + + ); + + await screen.findByRole('button', { name: /First Book/ }); + + fireEvent.change(screen.getByPlaceholderText('Search books...'), { target: { value: 'missing' } }); + + expect(screen.getByText('No books match your search')).toBeInTheDocument(); + }); + + it('clears selection and disables confirm', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ books }), + }); + vi.stubGlobal('fetch', fetchMock); + const { BookPickerModal } = await import('@/components/bookdate/BookPickerModal'); + + render( + + ); + + await screen.findByRole('button', { name: /First Book/ }); + + const clearButton = screen.getByRole('button', { name: 'Clear Selection' }); + fireEvent.click(clearButton); + + expect(screen.getByRole('button', { name: /Confirm Selection/ })).toBeDisabled(); + }); +}); diff --git a/tests/components/bookdate/CardStack.test.tsx b/tests/components/bookdate/CardStack.test.tsx new file mode 100644 index 0000000..8b1ba71 --- /dev/null +++ b/tests/components/bookdate/CardStack.test.tsx @@ -0,0 +1,89 @@ +/** + * Component: BookDate Card Stack Tests + * Documentation: documentation/features/bookdate-animations.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/components/bookdate/RecommendationCard', () => ({ + RecommendationCard: ({ + recommendation, + onSwipe, + stackPosition, + isDraggable, + }: { + recommendation: { id: string; title: string }; + onSwipe: (action: 'left' | 'right' | 'up') => void; + stackPosition: number; + isDraggable: boolean; + }) => ( + + ), +})); + +const recommendations = [ + { id: 'rec-1', title: 'Rec One' }, + { id: 'rec-2', title: 'Rec Two' }, + { id: 'rec-3', title: 'Rec Three' }, + { id: 'rec-4', title: 'Rec Four' }, +]; + +describe('CardStack', () => { + it('renders up to three cards and only the top card is draggable', async () => { + const { CardStack } = await import('@/components/bookdate/CardStack'); + + render( + + ); + + expect(screen.getByTestId('card-rec-1')).toHaveAttribute('data-stack', '0'); + expect(screen.getByTestId('card-rec-1')).toHaveAttribute('data-draggable', 'true'); + expect(screen.getByTestId('card-rec-2')).toHaveAttribute('data-stack', '1'); + expect(screen.getByTestId('card-rec-3')).toHaveAttribute('data-stack', '2'); + expect(screen.queryByTestId('card-rec-4')).toBeNull(); + }); + + it('locks swipes during animations and calls onSwipeComplete', async () => { + vi.useFakeTimers(); + const onSwipe = vi.fn(); + const onSwipeComplete = vi.fn(); + const { CardStack } = await import('@/components/bookdate/CardStack'); + + render( + + ); + + const topCard = screen.getByTestId('card-rec-1'); + fireEvent.click(topCard); + fireEvent.click(topCard); + + expect(onSwipe).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(750); + }); + expect(onSwipeComplete).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); +}); diff --git a/tests/components/bookdate/LoadingScreen.test.tsx b/tests/components/bookdate/LoadingScreen.test.tsx new file mode 100644 index 0000000..73fb312 --- /dev/null +++ b/tests/components/bookdate/LoadingScreen.test.tsx @@ -0,0 +1,25 @@ +/** + * Component: BookDate Loading Screen Tests + * Documentation: documentation/features/bookdate.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/components/layout/Header', () => ({ + Header: () =>
, +})); + +describe('LoadingScreen', () => { + it('renders the loading message and header', async () => { + const { LoadingScreen } = await import('@/components/bookdate/LoadingScreen'); + + render(); + + expect(screen.getByTestId('header')).toBeInTheDocument(); + expect(screen.getByText('Finding your next great listen...')).toBeInTheDocument(); + }); +}); diff --git a/tests/components/bookdate/RecommendationCard.test.tsx b/tests/components/bookdate/RecommendationCard.test.tsx new file mode 100644 index 0000000..903e712 --- /dev/null +++ b/tests/components/bookdate/RecommendationCard.test.tsx @@ -0,0 +1,135 @@ +/** + * Component: BookDate Recommendation Card Tests + * Documentation: documentation/features/bookdate.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const swipeHandlers: { + onSwiping?: (eventData: { deltaX: number; deltaY: number }) => void; + onSwiped?: (eventData: { deltaX: number; deltaY: number }) => void; +} = {}; + +vi.mock('react-swipeable', () => ({ + useSwipeable: (handlers: any) => { + swipeHandlers.onSwiping = handlers.onSwiping; + swipeHandlers.onSwiped = handlers.onSwiped; + return {}; + }, +})); + +const recommendation = { + title: 'Sample Book', + author: 'Sample Author', + narrator: 'Sample Narrator', + rating: 4.5, + description: 'A sample description', + aiReason: 'Because it matches your tastes.', +}; + +describe('RecommendationCard', () => { + beforeEach(() => { + swipeHandlers.onSwiping = undefined; + swipeHandlers.onSwiped = undefined; + }); + + it('shows the request toast and triggers a request action', async () => { + const onSwipe = vi.fn(); + const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard'); + + render(); + + const requestButtons = screen.getAllByRole('button', { name: /Request/ }); + fireEvent.click(requestButtons[0]); + + expect(screen.getByText('Request "Sample Book"?')).toBeInTheDocument(); + const toastRequestButtons = screen.getAllByRole('button', { name: /Request/ }); + fireEvent.click(toastRequestButtons[toastRequestButtons.length - 1]); + + expect(onSwipe).toHaveBeenCalledWith('right', false); + }); + + it('marks a recommendation as liked from the toast', async () => { + const onSwipe = vi.fn(); + const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard'); + + render(); + + const requestButtons = screen.getAllByRole('button', { name: /Request/ }); + fireEvent.click(requestButtons[0]); + + fireEvent.click(screen.getByRole('button', { name: 'Mark as Liked' })); + + expect(onSwipe).toHaveBeenCalledWith('right', true); + }); + + it('triggers dislike and dismiss actions from desktop buttons', async () => { + const onSwipe = vi.fn(); + const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard'); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /Not Interested/ })); + fireEvent.click(screen.getByRole('button', { name: /Dismiss/ })); + + expect(onSwipe).toHaveBeenCalledWith('left'); + expect(onSwipe).toHaveBeenCalledWith('up'); + }); + + it('shows drag overlays based on swipe direction', async () => { + const onSwipe = vi.fn(); + const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard'); + + render(); + + act(() => { + swipeHandlers.onSwiping?.({ deltaX: -80, deltaY: 0 }); + }); + + expect(screen.getByText('Dislike')).toBeInTheDocument(); + }); + + it('triggers an upward swipe from gesture handling', async () => { + const onSwipe = vi.fn(); + const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard'); + + render(); + + act(() => { + swipeHandlers.onSwiped?.({ deltaX: 0, deltaY: -150 }); + }); + + expect(onSwipe).toHaveBeenCalledWith('up'); + }); + + it('ignores swipe gestures when not draggable', async () => { + const onSwipe = vi.fn(); + const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard'); + + render( + + ); + + act(() => { + swipeHandlers.onSwiped?.({ deltaX: 150, deltaY: 0 }); + }); + + expect(onSwipe).not.toHaveBeenCalled(); + expect(screen.queryByText(/Request "Sample Book"/)).toBeNull(); + }); + + it('hides desktop actions when not the top card', async () => { + const onSwipe = vi.fn(); + const { RecommendationCard } = await import('@/components/bookdate/RecommendationCard'); + + render( + + ); + + expect(screen.queryByRole('button', { name: /Not Interested/ })).toBeNull(); + }); +}); diff --git a/tests/components/bookdate/SettingsWidget.test.tsx b/tests/components/bookdate/SettingsWidget.test.tsx new file mode 100644 index 0000000..4e4778f --- /dev/null +++ b/tests/components/bookdate/SettingsWidget.test.tsx @@ -0,0 +1,205 @@ +/** + * Component: BookDate Settings Widget Tests + * Documentation: documentation/features/bookdate.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/components/bookdate/BookPickerModal', () => ({ + BookPickerModal: ({ isOpen, onConfirm }: { isOpen: boolean; onConfirm: (ids: string[]) => void }) => + isOpen ? ( +
+ +
+ ) : null, +})); + +describe('SettingsWidget', () => { + afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it('loads preferences and populates the form', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + libraryScope: 'rated', + favoriteBookIds: ['book-1'], + customPrompt: 'Custom prompt', + backendCapabilities: { supportsRatings: true }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + localStorage.setItem('accessToken', 'token-789'); + const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget'); + + render(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/bookdate/preferences', { + headers: { Authorization: 'Bearer token-789' }, + }); + }); + + const ratedRadio = await screen.findByRole('radio', { name: /Rated Books Only/ }); + expect(ratedRadio).toBeChecked(); + expect(screen.getByDisplayValue('Custom prompt')).toBeInTheDocument(); + }); + + it('requires favorites selection before saving', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + libraryScope: 'full', + favoriteBookIds: [], + customPrompt: '', + backendCapabilities: { supportsRatings: true }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + localStorage.setItem('accessToken', 'token-000'); + const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget'); + + render(); + + const favoritesRadio = await screen.findByRole('radio', { name: /Pick my favorites/ }); + fireEvent.click(favoritesRadio); + fireEvent.click(screen.getByRole('button', { name: /Save Preferences/ })); + + expect(await screen.findByText('Please select at least 1 favorite book')).toBeInTheDocument(); + }); + + it('saves onboarding preferences and calls completion handlers', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + libraryScope: 'full', + favoriteBookIds: [], + customPrompt: '', + backendCapabilities: { supportsRatings: true }, + }), + }) + .mockResolvedValueOnce({ ok: true }); + vi.stubGlobal('fetch', fetchMock); + localStorage.setItem('accessToken', 'token-onboarding'); + const onClose = vi.fn(); + const onOnboardingComplete = vi.fn(); + const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget'); + + render( + + ); + + const letsGoButton = await screen.findByRole('button', { name: "Let's Go!" }); + vi.useFakeTimers(); + fireEvent.click(letsGoButton); + + await act(async () => { + await Promise.resolve(); + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const requestBody = JSON.parse(fetchMock.mock.calls[1][1].body as string); + expect(requestBody.onboardingComplete).toBe(true); + expect(requestBody.customPrompt).toBeNull(); + expect(requestBody.libraryScope).toBe('full'); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + expect(onOnboardingComplete).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('hides rated scope when backend does not support ratings', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + libraryScope: 'full', + favoriteBookIds: [], + customPrompt: '', + backendCapabilities: { supportsRatings: false }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + localStorage.setItem('accessToken', 'token-no-ratings'); + const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget'); + + render(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled(); + }); + + expect(screen.queryByRole('radio', { name: /Rated Books Only/ })).toBeNull(); + }); + + it('shows an error when loading preferences fails', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({}), + }); + vi.stubGlobal('fetch', fetchMock); + localStorage.setItem('accessToken', 'token-fail'); + const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget'); + + render(); + + expect(await screen.findByText('Failed to load preferences')).toBeInTheDocument(); + }); + + it('saves preferences and clears success message after delay', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + libraryScope: 'full', + favoriteBookIds: [], + customPrompt: '', + backendCapabilities: { supportsRatings: true }, + }), + }) + .mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + vi.stubGlobal('fetch', fetchMock); + localStorage.setItem('accessToken', 'token-save'); + const { SettingsWidget } = await import('@/components/bookdate/SettingsWidget'); + + render(); + + const promptInput = await screen.findByLabelText(/Special Requests/); + fireEvent.change(promptInput, { target: { value: ' trimmed ' } }); + + vi.useFakeTimers(); + fireEvent.click(screen.getByRole('button', { name: 'Save Preferences' })); + + await act(async () => { + await Promise.resolve(); + }); + + const requestBody = JSON.parse(fetchMock.mock.calls[1][1].body as string); + expect(requestBody.customPrompt).toBe('trimmed'); + expect(requestBody.onboardingComplete).toBeUndefined(); + + expect(screen.getByText('Preferences saved successfully!')).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(3000); + }); + + expect(screen.queryByText('Preferences saved successfully!')).toBeNull(); + vi.useRealTimers(); + }); +}); diff --git a/tests/components/layout/Header.test.tsx b/tests/components/layout/Header.test.tsx new file mode 100644 index 0000000..acb5fd1 --- /dev/null +++ b/tests/components/layout/Header.test.tsx @@ -0,0 +1,226 @@ +/** + * Component: Header Component Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../helpers/render'; + +describe('Header', () => { + let Header: typeof import('@/components/layout/Header').Header; + + beforeAll(async () => { + ({ Header } = await import('@/components/layout/Header')); + }); + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders login button and opens Plex auth window', async () => { + const fetchMock = vi.fn().mockImplementation((input: RequestInfo) => { + if (input === '/api/version') { + return Promise.resolve({ + json: vi.fn().mockResolvedValue({ version: 'v.test' }), + }); + } + + return Promise.resolve({ + json: vi.fn().mockResolvedValue({ success: true, authUrl: 'https://plex.example/login' }), + }); + }); + const openMock = vi.spyOn(window, 'open').mockImplementation(() => null); + + vi.stubGlobal('fetch', fetchMock); + + renderWithProviders(
, { auth: { user: null, isLoading: false } }); + + const loginButton = screen.getByRole('button', { name: /login with plex/i }); + await userEvent.click(loginButton); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('/api/auth/plex/login', { method: 'POST' }); + }); + expect(openMock).toHaveBeenCalledWith( + 'https://plex.example/login', + 'plex-auth', + 'width=600,height=700' + ); + }); + + it('renders admin navigation and user menu actions for local users', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ version: 'v.test' }), + }); + + vi.stubGlobal('fetch', fetchMock); + + renderWithProviders(
, { + auth: { + user: { + id: 'admin-1', + plexId: 'plex-1', + username: 'admin', + role: 'admin', + authProvider: 'local', + }, + isLoading: false, + }, + }); + + expect(screen.getByRole('link', { name: 'Admin' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'My Requests' })).toBeInTheDocument(); + + const userButton = screen.getByText('admin').closest('button'); + expect(userButton).not.toBeNull(); + await userEvent.click(userButton as HTMLButtonElement); + + await waitFor(() => { + expect(screen.getByText('Change Password')).toBeInTheDocument(); + }); + expect(screen.getByText('Logout')).toBeInTheDocument(); + }); + + it('shows BookDate link and avatar when BookDate is enabled', async () => { + localStorage.setItem('accessToken', 'token'); + const fetchMock = vi.fn().mockImplementation((input: RequestInfo) => { + if (input === '/api/version') { + return Promise.resolve({ + json: vi.fn().mockResolvedValue({ version: 'v.test' }), + }); + } + if (input === '/api/bookdate/config') { + return Promise.resolve({ + json: vi.fn().mockResolvedValue({ + config: { isVerified: true, isEnabled: true }, + }), + }); + } + return Promise.resolve({ + json: vi.fn().mockResolvedValue({}), + }); + }); + + vi.stubGlobal('fetch', fetchMock); + + renderWithProviders(
, { + auth: { + user: { + id: 'user-1', + plexId: 'plex-1', + username: 'reader', + role: 'user', + avatarUrl: '/avatar.png', + }, + isLoading: false, + }, + }); + + await waitFor(() => { + expect(screen.getByRole('link', { name: 'BookDate' })).toBeInTheDocument(); + }); + expect(screen.getByAltText('reader')).toBeInTheDocument(); + }); + + it('logs out from the user menu and shows initials fallback', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ version: 'v.test' }), + }); + const logoutMock = vi.fn(); + + vi.stubGlobal('fetch', fetchMock); + + renderWithProviders(
, { + auth: { + user: { + id: 'user-2', + plexId: 'plex-2', + username: 'alice', + role: 'user', + authProvider: 'plex', + }, + logout: logoutMock, + isLoading: false, + }, + }); + + expect(screen.getByText(/^A$/)).toBeInTheDocument(); + + const userButton = screen.getByText('alice').closest('button'); + expect(userButton).not.toBeNull(); + await userEvent.click(userButton as HTMLButtonElement); + + await waitFor(() => { + expect(screen.getByText('Logout')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByText('Logout')); + + expect(logoutMock).toHaveBeenCalledTimes(1); + }); + + it('toggles the mobile menu and closes after navigation', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue({ version: 'v.test' }), + }); + vi.stubGlobal('fetch', fetchMock); + + renderWithProviders(
, { auth: { user: null, isLoading: false } }); + + const initialHomeLinks = screen.getAllByRole('link', { name: 'Home' }).length; + + await userEvent.click(screen.getByRole('button', { name: 'Toggle menu' })); + + const openHomeLinks = screen.getAllByRole('link', { name: 'Home' }); + expect(openHomeLinks).toHaveLength(initialHomeLinks + 1); + + await userEvent.click(openHomeLinks[openHomeLinks.length - 1]); + expect(screen.getAllByRole('link', { name: 'Home' })).toHaveLength(initialHomeLinks); + }); + + it('hides BookDate when config check fails', async () => { + localStorage.setItem('accessToken', 'token'); + const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const fetchMock = vi.fn().mockImplementation((input: RequestInfo) => { + if (input === '/api/version') { + return Promise.resolve({ + json: vi.fn().mockResolvedValue({ version: 'v.test' }), + }); + } + if (input === '/api/bookdate/config') { + return Promise.reject(new Error('boom')); + } + return Promise.resolve({ + json: vi.fn().mockResolvedValue({}), + }); + }); + + vi.stubGlobal('fetch', fetchMock); + + renderWithProviders(
, { + auth: { + user: { + id: 'user-3', + plexId: 'plex-3', + username: 'reader', + role: 'user', + }, + isLoading: false, + }, + }); + + await waitFor(() => { + expect(errorMock).toHaveBeenCalledWith('Failed to check BookDate config:', expect.any(Error)); + }); + + expect(screen.queryByRole('link', { name: 'BookDate' })).not.toBeInTheDocument(); + }); +}); diff --git a/tests/components/requests/InteractiveTorrentSearchModal.test.tsx b/tests/components/requests/InteractiveTorrentSearchModal.test.tsx new file mode 100644 index 0000000..1ca1b59 --- /dev/null +++ b/tests/components/requests/InteractiveTorrentSearchModal.test.tsx @@ -0,0 +1,144 @@ +/** + * Component: Interactive Torrent Search Modal Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +const searchByRequestMock = vi.hoisted(() => vi.fn()); +const selectTorrentMock = vi.hoisted(() => vi.fn()); +const searchByAudiobookMock = vi.hoisted(() => vi.fn()); +const requestWithTorrentMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/hooks/useRequests', () => ({ + useInteractiveSearch: () => ({ + searchTorrents: searchByRequestMock, + isLoading: false, + error: null, + }), + useSelectTorrent: () => ({ + selectTorrent: selectTorrentMock, + isLoading: false, + error: null, + }), + useSearchTorrents: () => ({ + searchTorrents: searchByAudiobookMock, + isLoading: false, + error: null, + }), + useRequestWithTorrent: () => ({ + requestWithTorrent: requestWithTorrentMock, + isLoading: false, + error: null, + }), +})); + +const baseResult = { + guid: 'torrent-1', + rank: 1, + title: 'Test Torrent', + size: 2.4 * 1024 ** 3, + score: 88, + bonusPoints: 5, + seeders: 42, + indexer: 'ProIndexer', + format: 'M4B', + infoUrl: 'https://example.com/torrent', +}; + +describe('InteractiveTorrentSearchModal', () => { + it('searches by request id on open and confirms download', async () => { + searchByRequestMock.mockResolvedValueOnce([baseResult]); + selectTorrentMock.mockResolvedValueOnce(undefined); + const onClose = vi.fn(); + const onSuccess = vi.fn(); + const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal'); + + render( + + ); + + await waitFor(() => { + expect(searchByRequestMock).toHaveBeenCalledWith('req-123', undefined); + }); + + expect(await screen.findByText('Test Torrent')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + const downloadButtons = screen.getAllByRole('button', { name: 'Download' }); + fireEvent.click(downloadButtons[downloadButtons.length - 1]); + + await waitFor(() => { + expect(selectTorrentMock).toHaveBeenCalledWith('req-123', baseResult); + }); + expect(onSuccess).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('searches by audiobook data and requests with torrent', async () => { + searchByAudiobookMock.mockResolvedValueOnce([baseResult]); + requestWithTorrentMock.mockResolvedValueOnce(undefined); + const onClose = vi.fn(); + const fullAudiobook = { asin: 'ASIN-1', title: 'Test Book', author: 'Test Author' }; + const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal'); + + render( + + ); + + await waitFor(() => { + expect(searchByAudiobookMock).toHaveBeenCalledWith('Test Book', 'Test Author', 'ASIN-1'); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Download' })); + const downloadButtons = screen.getAllByRole('button', { name: 'Download' }); + fireEvent.click(downloadButtons[downloadButtons.length - 1]); + + await waitFor(() => { + expect(requestWithTorrentMock).toHaveBeenCalledWith(fullAudiobook, baseResult); + }); + expect(onClose).toHaveBeenCalled(); + }); + + it('uses a custom title when pressing Enter', async () => { + searchByRequestMock.mockResolvedValueOnce([]); + searchByRequestMock.mockResolvedValueOnce([]); + const { InteractiveTorrentSearchModal } = await import('@/components/requests/InteractiveTorrentSearchModal'); + + render( + + ); + + await waitFor(() => { + expect(searchByRequestMock).toHaveBeenCalledWith('req-456', undefined); + }); + + const input = screen.getByPlaceholderText('Enter book title to search...'); + fireEvent.change(input, { target: { value: 'Custom Title' } }); + fireEvent.keyPress(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + await waitFor(() => { + expect(searchByRequestMock).toHaveBeenNthCalledWith(2, 'req-456', 'Custom Title'); + }); + }); +}); diff --git a/tests/components/requests/RequestCard.test.tsx b/tests/components/requests/RequestCard.test.tsx new file mode 100644 index 0000000..e871bbf --- /dev/null +++ b/tests/components/requests/RequestCard.test.tsx @@ -0,0 +1,187 @@ +/** + * Component: Request Card Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const cancelRequestMock = vi.hoisted(() => vi.fn()); +const manualSearchMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/hooks/useRequests', () => ({ + useCancelRequest: () => ({ cancelRequest: cancelRequestMock, isLoading: false }), + useManualSearch: () => ({ triggerManualSearch: manualSearchMock, isLoading: false }), +})); + +vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({ + InteractiveTorrentSearchModal: ({ + isOpen, + requestId, + }: { + isOpen: boolean; + requestId?: string; + }) => ( +
+ ), +})); + +vi.mock('next/image', () => ({ + __esModule: true, + default: (props: any) => , +})); + +const baseRequest = { + id: 'req-1', + status: 'pending', + progress: 0, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + audiobook: { + id: 'book-1', + title: 'Test Book', + author: 'Test Author', + }, +}; + +describe('RequestCard', () => { + beforeEach(() => { + cancelRequestMock.mockReset(); + manualSearchMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('shows progress and active indicator for downloads', async () => { + const { RequestCard } = await import('@/components/requests/RequestCard'); + + render( + + ); + + expect(screen.getByText('Downloading')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('45%')).toBeInTheDocument(); + }); + + it('toggles the error message for failed requests', async () => { + const { RequestCard } = await import('@/components/requests/RequestCard'); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Show error' })); + expect(await screen.findByText('Failure details')).toBeInTheDocument(); + }); + + it('triggers manual search, interactive search, and cancel actions', async () => { + const { RequestCard } = await import('@/components/requests/RequestCard'); + + manualSearchMock.mockResolvedValueOnce(undefined); + cancelRequestMock.mockResolvedValueOnce(undefined); + vi.spyOn(window, 'confirm').mockReturnValue(true); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Manual Search' })); + await waitFor(() => { + expect(manualSearchMock).toHaveBeenCalledWith('req-1'); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Interactive Search' })); + expect(screen.getByTestId('interactive-modal')).toHaveAttribute('data-open', 'true'); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => { + expect(cancelRequestMock).toHaveBeenCalledWith('req-1'); + }); + }); + + it('shows setup indicator when progress is zero', async () => { + const { RequestCard } = await import('@/components/requests/RequestCard'); + + render( + + ); + + expect(screen.getByText('Setting up...')).toBeInTheDocument(); + }); + + it('hides action buttons when showActions is false', async () => { + const { RequestCard } = await import('@/components/requests/RequestCard'); + + render(); + + expect(screen.queryByRole('button', { name: 'Manual Search' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull(); + }); + + it('alerts when manual search fails', async () => { + const { RequestCard } = await import('@/components/requests/RequestCard'); + + manualSearchMock.mockRejectedValueOnce(new Error('Search failed')); + const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Manual Search' })); + + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith('Search failed'); + }); + }); + + it('does not cancel when confirmation is declined', async () => { + const { RequestCard } = await import('@/components/requests/RequestCard'); + + vi.spyOn(window, 'confirm').mockReturnValue(false); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + await waitFor(() => { + expect(cancelRequestMock).not.toHaveBeenCalled(); + }); + }); + + it('shows completed timestamp when available', async () => { + const { RequestCard } = await import('@/components/requests/RequestCard'); + + render( + + ); + + expect(screen.getByText(/Completed/)).toBeInTheDocument(); + }); +}); diff --git a/tests/components/requests/StatusBadge.test.tsx b/tests/components/requests/StatusBadge.test.tsx new file mode 100644 index 0000000..5879552 --- /dev/null +++ b/tests/components/requests/StatusBadge.test.tsx @@ -0,0 +1,23 @@ +/** + * Component: Status Badge Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { StatusBadge } from '@/components/requests/StatusBadge'; + +describe('StatusBadge', () => { + it('uses the initializing label for zero-progress downloads', () => { + render(); + expect(screen.getByText('Initializing...')).toBeInTheDocument(); + }); + + it('falls back to the raw status when unknown', () => { + render(); + expect(screen.getByText('custom_status')).toBeInTheDocument(); + }); +}); diff --git a/tests/components/ui/CardSizeControls.test.tsx b/tests/components/ui/CardSizeControls.test.tsx new file mode 100644 index 0000000..326ffc1 --- /dev/null +++ b/tests/components/ui/CardSizeControls.test.tsx @@ -0,0 +1,44 @@ +/** + * Component: Card Size Controls Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('CardSizeControls', () => { + beforeEach(() => { + window.innerWidth = 1300; + }); + + it('moves to the next visible size when zooming in or out', async () => { + const onSizeChange = vi.fn(); + const { CardSizeControls } = await import('@/components/ui/CardSizeControls'); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })); + expect(onSizeChange).toHaveBeenCalledWith(6); + + onSizeChange.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })); + expect(onSizeChange).toHaveBeenCalledWith(4); + }); + + it('disables zoom controls at the size boundaries', async () => { + const onSizeChange = vi.fn(); + const { CardSizeControls } = await import('@/components/ui/CardSizeControls'); + + const { rerender } = render(); + + expect(screen.getByRole('button', { name: 'Zoom out' })).toBeDisabled(); + + rerender(); + + expect(screen.getByRole('button', { name: 'Zoom in' })).toBeDisabled(); + }); +}); diff --git a/tests/components/ui/ChangePasswordModal.test.tsx b/tests/components/ui/ChangePasswordModal.test.tsx new file mode 100644 index 0000000..598b03e --- /dev/null +++ b/tests/components/ui/ChangePasswordModal.test.tsx @@ -0,0 +1,121 @@ +/** + * Component: Change Password Modal Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ChangePasswordModal } from '@/components/ui/ChangePasswordModal'; + +describe('ChangePasswordModal', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + localStorage.clear(); + }); + + it('shows validation errors when required fields are missing', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Change Password' })); + + expect(screen.getByText('Current password is required')).toBeInTheDocument(); + expect(screen.getByText('New password is required')).toBeInTheDocument(); + expect(screen.getByText('Please confirm your new password')).toBeInTheDocument(); + }); + + it('rejects submission when access token is missing', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + render(); + + fireEvent.change(screen.getByLabelText('Current Password'), { + target: { value: 'old-password' }, + }); + fireEvent.change(screen.getByLabelText('New Password'), { + target: { value: 'new-password' }, + }); + fireEvent.change(screen.getByLabelText('Confirm New Password'), { + target: { value: 'new-password' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Change Password' })); + + await waitFor(() => { + expect(screen.getByText('Not authenticated')).toBeInTheDocument(); + }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('submits successfully and auto-closes after showing success', async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); + vi.stubGlobal('fetch', fetchMock); + localStorage.setItem('accessToken', 'token'); + + render(); + + fireEvent.change(screen.getByLabelText('Current Password'), { + target: { value: 'old-password' }, + }); + fireEvent.change(screen.getByLabelText('New Password'), { + target: { value: 'new-password' }, + }); + fireEvent.change(screen.getByLabelText('Confirm New Password'), { + target: { value: 'new-password' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Change Password' })); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/auth/change-password', + expect.objectContaining({ method: 'POST' }) + ); + + expect(screen.getByText('Password changed successfully!')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('shows server error responses', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ error: 'Invalid password' }), + }); + vi.stubGlobal('fetch', fetchMock); + localStorage.setItem('accessToken', 'token'); + + render(); + + fireEvent.change(screen.getByLabelText('Current Password'), { + target: { value: 'old-password' }, + }); + fireEvent.change(screen.getByLabelText('New Password'), { + target: { value: 'new-password' }, + }); + fireEvent.change(screen.getByLabelText('Confirm New Password'), { + target: { value: 'new-password' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Change Password' })); + + expect(await screen.findByText('Invalid password')).toBeInTheDocument(); + }); +}); diff --git a/tests/components/ui/Modal.test.tsx b/tests/components/ui/Modal.test.tsx new file mode 100644 index 0000000..8f300c8 --- /dev/null +++ b/tests/components/ui/Modal.test.tsx @@ -0,0 +1,31 @@ +/** + * Component: Modal Component Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Modal } from '@/components/ui/Modal'; + +describe('Modal', () => { + it('locks body scroll while open and closes on escape', () => { + const onClose = vi.fn(); + + const { unmount } = render( + +
Modal Content
+
+ ); + + expect(screen.getByText('Modal Content')).toBeInTheDocument(); + expect(document.body.style.overflow).toBe('hidden'); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + + unmount(); + expect(document.body.style.overflow).toBe('unset'); + }); +}); diff --git a/tests/components/ui/Pagination.test.tsx b/tests/components/ui/Pagination.test.tsx new file mode 100644 index 0000000..7b9adc5 --- /dev/null +++ b/tests/components/ui/Pagination.test.tsx @@ -0,0 +1,38 @@ +/** + * Component: Pagination Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Pagination } from '@/components/ui/Pagination'; + +describe('Pagination', () => { + it('renders nothing when there is only one page', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders ellipses for large page ranges', () => { + render(); + const ellipses = screen.getAllByText('...'); + expect(ellipses.length).toBeGreaterThan(0); + }); + + it('calls onPageChange for navigation controls', () => { + const onPageChange = vi.fn(); + render(); + + fireEvent.click(screen.getByLabelText('Previous page')); + expect(onPageChange).toHaveBeenCalledWith(1); + + fireEvent.click(screen.getByLabelText('Next page')); + expect(onPageChange).toHaveBeenCalledWith(3); + + fireEvent.click(screen.getByLabelText('Page 4')); + expect(onPageChange).toHaveBeenCalledWith(4); + }); +}); diff --git a/tests/components/ui/StickyPagination.test.tsx b/tests/components/ui/StickyPagination.test.tsx new file mode 100644 index 0000000..4c2206a --- /dev/null +++ b/tests/components/ui/StickyPagination.test.tsx @@ -0,0 +1,133 @@ +/** + * Component: Sticky Pagination Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { StickyPagination } from '@/components/ui/StickyPagination'; + +type ObserverEntry = { + isIntersecting: boolean; + intersectionRatio: number; + target: Element; +}; + +describe('StickyPagination', () => { + const observers: { callback: IntersectionObserverCallback }[] = []; + + beforeEach(() => { + observers.length = 0; + class MockIntersectionObserver { + callback: IntersectionObserverCallback; + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + takeRecords = vi.fn(); + + constructor(callback: IntersectionObserverCallback) { + this.callback = callback; + observers.push(this); + } + } + + (global as any).IntersectionObserver = MockIntersectionObserver; + }); + + it('returns null when there is only one page', () => { + const sectionRef = { current: document.createElement('div') }; + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('shows and hides based on section and footer visibility', () => { + const sectionRef = { current: document.createElement('div') }; + const footerRef = { current: document.createElement('div') }; + + const { container } = render( + + ); + + const root = container.querySelector('div.fixed') as HTMLElement; + expect(root).toHaveClass('opacity-0'); + + act(() => { + observers[0].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.2, + target: sectionRef.current as Element, + } as ObserverEntry, + ], + observers[0] as unknown as IntersectionObserver + ); + }); + + expect(root).toHaveClass('opacity-100'); + + act(() => { + observers[1].callback( + [ + { + isIntersecting: true, + intersectionRatio: 0.2, + target: footerRef.current as Element, + } as ObserverEntry, + ], + observers[1] as unknown as IntersectionObserver + ); + }); + + expect(root).toHaveClass('opacity-0'); + }); + + it('handles navigation and jump input updates', () => { + const sectionRef = { current: document.createElement('div') }; + const onPageChange = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByLabelText('Next page')); + expect(onPageChange).toHaveBeenCalledWith(3); + + fireEvent.click(screen.getByLabelText('Previous page')); + expect(onPageChange).toHaveBeenCalledWith(1); + + const input = screen.getByLabelText('Current page') as HTMLInputElement; + fireEvent.change(input, { target: { value: '4' } }); + fireEvent.blur(input); + expect(onPageChange).toHaveBeenCalledWith(4); + + fireEvent.change(input, { target: { value: '99' } }); + fireEvent.blur(input); + expect(input.value).toBe('2'); + }); +}); diff --git a/tests/components/ui/Toast.test.tsx b/tests/components/ui/Toast.test.tsx new file mode 100644 index 0000000..82270a2 --- /dev/null +++ b/tests/components/ui/Toast.test.tsx @@ -0,0 +1,67 @@ +/** + * Component: Toast Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ToastProvider, useToast } from '@/components/ui/Toast'; + +const ToastHarness = () => { + const { success, error } = useToast(); + + return ( +
+ + +
+ ); +}; + +describe('ToastProvider', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('adds and auto-removes toasts after the duration', async () => { + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Add Success' })); + expect(screen.getByText('Saved')).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + expect(screen.queryByText('Saved')).toBeNull(); + }); + + it('removes a toast when the close button is clicked', () => { + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Add Error' })); + expect(screen.getByText('Failed')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(screen.queryByText('Failed')).toBeNull(); + }); +}); diff --git a/tests/components/ui/VersionBadge.test.tsx b/tests/components/ui/VersionBadge.test.tsx new file mode 100644 index 0000000..e2cbb0b --- /dev/null +++ b/tests/components/ui/VersionBadge.test.tsx @@ -0,0 +1,61 @@ +/** + * Component: Version Badge Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { VersionBadge } from '@/components/ui/VersionBadge'; + +const originalCommit = process.env.NEXT_PUBLIC_GIT_COMMIT; + +describe('VersionBadge', () => { + afterEach(() => { + vi.unstubAllGlobals(); + if (originalCommit === undefined) { + delete process.env.NEXT_PUBLIC_GIT_COMMIT; + } else { + process.env.NEXT_PUBLIC_GIT_COMMIT = originalCommit; + } + }); + + it('renders short version from build-time commit', async () => { + process.env.NEXT_PUBLIC_GIT_COMMIT = 'abcdef1234'; + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + render(); + + expect(await screen.findByText('v.abcdef1')).toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('falls back to API when build-time commit is unavailable', async () => { + process.env.NEXT_PUBLIC_GIT_COMMIT = 'unknown'; + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ version: 'v.1.2.3' }), + }); + vi.stubGlobal('fetch', fetchMock); + + render(); + + expect(await screen.findByText('v.1.2.3')).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith('/api/version'); + }); + + it('shows dev version when API fetch fails', async () => { + process.env.NEXT_PUBLIC_GIT_COMMIT = 'unknown'; + const fetchMock = vi.fn().mockRejectedValue(new Error('down')); + const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.stubGlobal('fetch', fetchMock); + + render(); + + await waitFor(() => { + expect(screen.getByText('v.dev')).toBeInTheDocument(); + }); + expect(errorMock).toHaveBeenCalledWith('Failed to fetch version:', expect.any(Error)); + }); +}); diff --git a/tests/contexts/AuthContext.test.tsx b/tests/contexts/AuthContext.test.tsx new file mode 100644 index 0000000..72e0bcf --- /dev/null +++ b/tests/contexts/AuthContext.test.tsx @@ -0,0 +1,287 @@ +/** + * Component: Authentication Context Tests + * Documentation: documentation/backend/services/auth.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthProvider, useAuth } from '@/contexts/AuthContext'; + +const isTokenExpiredMock = vi.hoisted(() => vi.fn()); +const getRefreshTimeMsMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/utils/jwt-client', () => ({ + isTokenExpired: isTokenExpiredMock, + getRefreshTimeMs: getRefreshTimeMsMock, +})); + +function TestConsumer() { + const { user, accessToken, isLoading, login, logout, refreshToken, setAuthData } = useAuth(); + + return ( +
+
{String(isLoading)}
+
{user?.username ?? 'none'}
+
{accessToken ?? 'none'}
+ + + + +
+ ); +} + +function renderAuthProvider() { + return render( + + + + ); +} + +describe('AuthProvider', () => { + let locationStub: { href: string; pathname: string }; + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + sessionStorage.clear(); + + isTokenExpiredMock.mockReturnValue(false); + getRefreshTimeMsMock.mockReturnValue(300_000); + + locationStub = { href: 'http://localhost/', pathname: '/' }; + vi.stubGlobal('location', locationStub); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('restores session and refreshes user details with a valid token', async () => { + const storedUser = { + id: 'user-1', + plexId: 'plex-1', + username: 'old-user', + role: 'user', + }; + + localStorage.setItem('accessToken', 'access-token'); + localStorage.setItem('user', JSON.stringify(storedUser)); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ user: { ...storedUser, username: 'fresh-user' } }), + }); + + vi.stubGlobal('fetch', fetchMock); + + renderAuthProvider(); + + await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('fresh-user')); + + expect(screen.getByTestId('token')).toHaveTextContent('access-token'); + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + expect(fetchMock).toHaveBeenCalledWith( + '/api/auth/me', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer access-token', + }), + }) + ); + + const storedUserJson = JSON.parse(localStorage.getItem('user') ?? '{}') as { username?: string }; + expect(storedUserJson.username).toBe('fresh-user'); + }); + + it('refreshes the access token on mount when the access token is expired', async () => { + isTokenExpiredMock.mockImplementation((token: string) => token.startsWith('expired')); + + localStorage.setItem('accessToken', 'expired-access'); + localStorage.setItem('refreshToken', 'refresh-token'); + localStorage.setItem('user', JSON.stringify({ id: 'user-2' })); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ accessToken: 'new-access' }), + }); + + vi.stubGlobal('fetch', fetchMock); + + renderAuthProvider(); + + await waitFor(() => expect(screen.getByTestId('token')).toHaveTextContent('new-access')); + + expect(screen.getByTestId('loading')).toHaveTextContent('false'); + expect(localStorage.getItem('accessToken')).toBe('new-access'); + expect(fetchMock).toHaveBeenCalledWith( + '/api/auth/refresh', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + + it('clears stored auth data when both tokens are expired', async () => { + isTokenExpiredMock.mockImplementation((token: string) => token.startsWith('expired')); + + localStorage.setItem('accessToken', 'expired-access'); + localStorage.setItem('refreshToken', 'expired-refresh'); + localStorage.setItem('user', JSON.stringify({ id: 'user-3' })); + + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + renderAuthProvider(); + + await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false')); + + expect(localStorage.getItem('accessToken')).toBeNull(); + expect(localStorage.getItem('refreshToken')).toBeNull(); + expect(localStorage.getItem('user')).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('stores tokens and user data after a successful login', async () => { + const loginUser = { + id: 'user-4', + plexId: 'plex-4', + username: 'plex-user', + role: 'user', + }; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + success: true, + authorized: true, + accessToken: 'login-access', + refreshToken: 'login-refresh', + user: loginUser, + }), + }); + + vi.stubGlobal('fetch', fetchMock); + + renderAuthProvider(); + + fireEvent.click(screen.getByRole('button', { name: 'login' })); + + await waitFor(() => expect(screen.getByTestId('user')).toHaveTextContent('plex-user')); + + expect(screen.getByTestId('token')).toHaveTextContent('login-access'); + expect(localStorage.getItem('accessToken')).toBe('login-access'); + expect(localStorage.getItem('refreshToken')).toBe('login-refresh'); + }); + + it('logs out by clearing storage and redirecting to the login page', () => { + localStorage.setItem('accessToken', 'access-token'); + localStorage.setItem('refreshToken', 'refresh-token'); + localStorage.setItem('user', JSON.stringify({ id: 'user-5' })); + + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }); + vi.stubGlobal('fetch', fetchMock); + + renderAuthProvider(); + + fireEvent.click(screen.getByRole('button', { name: 'logout' })); + + expect(localStorage.getItem('accessToken')).toBeNull(); + expect(localStorage.getItem('refreshToken')).toBeNull(); + expect(localStorage.getItem('user')).toBeNull(); + expect(fetchMock).toHaveBeenCalledWith('/api/auth/logout', { method: 'POST' }); + expect(locationStub.href).toContain('/login'); + }); + + it('throws when useAuth is used outside the provider', () => { + function BrokenConsumer() { + useAuth(); + return null; + } + + expect(() => render()).toThrow('useAuth must be used within an AuthProvider'); + }); + + it('sets auth data directly and updates state', async () => { + renderAuthProvider(); + + fireEvent.click(screen.getByRole('button', { name: 'setAuth' })); + + await waitFor(() => { + expect(screen.getByTestId('user')).toHaveTextContent('set-user'); + }); + + expect(screen.getByTestId('token')).toHaveTextContent('set-token'); + }); + + it('refreshes token when refreshToken is called', async () => { + localStorage.setItem('refreshToken', 'refresh-token'); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ accessToken: 'refreshed-token' }), + }); + vi.stubGlobal('fetch', fetchMock); + + renderAuthProvider(); + + fireEvent.click(screen.getByRole('button', { name: 'refresh' })); + + await waitFor(() => { + expect(screen.getByTestId('token')).toHaveTextContent('refreshed-token'); + }); + + expect(localStorage.getItem('accessToken')).toBe('refreshed-token'); + }); + + it('logs out when access token is removed in another tab', async () => { + renderAuthProvider(); + + fireEvent.click(screen.getByRole('button', { name: 'setAuth' })); + await waitFor(() => { + expect(screen.getByTestId('token')).toHaveTextContent('set-token'); + }); + + act(() => { + window.dispatchEvent(new StorageEvent('storage', { key: 'accessToken', newValue: null })); + }); + + await waitFor(() => { + expect(screen.getByTestId('token')).toHaveTextContent('none'); + }); + + expect(locationStub.href).toContain('/login'); + }); + + it('syncs auth data when access token is added in another tab', async () => { + localStorage.setItem('user', JSON.stringify({ id: 'user-sync', plexId: 'plex-sync', username: 'synced', role: 'user' })); + + renderAuthProvider(); + + act(() => { + window.dispatchEvent(new StorageEvent('storage', { key: 'accessToken', newValue: 'synced-token' })); + }); + + await waitFor(() => { + expect(screen.getByTestId('user')).toHaveTextContent('synced'); + }); + + expect(screen.getByTestId('token')).toHaveTextContent('synced-token'); + }); +}); diff --git a/tests/contexts/PreferencesContext.test.tsx b/tests/contexts/PreferencesContext.test.tsx new file mode 100644 index 0000000..339ca6d --- /dev/null +++ b/tests/contexts/PreferencesContext.test.tsx @@ -0,0 +1,80 @@ +/** + * Component: Preferences Context Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { PreferencesProvider, usePreferences } from '@/contexts/PreferencesContext'; + +const TestConsumer = () => { + const { cardSize, setCardSize } = usePreferences(); + + return ( +
+ {cardSize} + +
+ ); +}; + +describe('PreferencesContext', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('loads card size from localStorage when valid', async () => { + localStorage.setItem('preferences', JSON.stringify({ cardSize: 7 })); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('size')).toHaveTextContent('7'); + }); + }); + + it('clamps card size updates and persists them', async () => { + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Set Large' })); + + await waitFor(() => { + expect(screen.getByTestId('size')).toHaveTextContent('9'); + }); + + const stored = JSON.parse(localStorage.getItem('preferences') || '{}'); + expect(stored.cardSize).toBe(9); + }); + + it('updates card size when a storage event is received', async () => { + render( + + + + ); + + await act(async () => { + window.dispatchEvent( + new StorageEvent('storage', { + key: 'preferences', + newValue: JSON.stringify({ cardSize: 3 }), + }) + ); + }); + + expect(screen.getByTestId('size')).toHaveTextContent('3'); + }); +}); diff --git a/tests/helpers/mock-auth.ts b/tests/helpers/mock-auth.ts new file mode 100644 index 0000000..9ba09b6 --- /dev/null +++ b/tests/helpers/mock-auth.ts @@ -0,0 +1,47 @@ +/** + * Component: Auth Context Test Mock + * Documentation: documentation/frontend/routing-auth.md + */ + +import React from 'react'; +import { vi } from 'vitest'; + +export interface MockUser { + id: string; + plexId: string; + username: string; + role: string; + email?: string; + avatarUrl?: string; + authProvider?: string | null; +} + +const authState = vi.hoisted(() => ({ + user: null as MockUser | null, + accessToken: null as string | null, + isLoading: false, + login: vi.fn(), + logout: vi.fn(), + refreshToken: vi.fn(), + setAuthData: vi.fn(), +})); + +export const setMockAuthState = (overrides: Partial) => { + Object.assign(authState, overrides); +}; + +export const resetMockAuthState = () => { + authState.user = null; + authState.accessToken = null; + authState.isLoading = false; + authState.login.mockReset(); + authState.logout.mockReset(); + authState.refreshToken.mockReset(); + authState.setAuthData.mockReset(); +}; + +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => authState, + AuthProvider: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), +})); diff --git a/tests/helpers/mock-next-navigation.ts b/tests/helpers/mock-next-navigation.ts new file mode 100644 index 0000000..c5d8150 --- /dev/null +++ b/tests/helpers/mock-next-navigation.ts @@ -0,0 +1,47 @@ +/** + * Component: Next Navigation Test Mock + * Documentation: documentation/frontend/routing-auth.md + */ + +import { vi } from 'vitest'; + +const router = vi.hoisted(() => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), +})); + +let pathname = '/'; +let searchParams = new URLSearchParams(); + +export const routerMock = router; + +export const setMockPathname = (value: string) => { + pathname = value; +}; + +export const setMockSearchParams = (value: string | URLSearchParams) => { + searchParams = typeof value === 'string' ? new URLSearchParams(value) : value; +}; + +export const resetMockRouter = () => { + router.push.mockReset(); + router.replace.mockReset(); + router.prefetch.mockReset(); + router.refresh.mockReset(); + router.back.mockReset(); + router.forward.mockReset(); + pathname = '/'; + searchParams = new URLSearchParams(); +}; + +vi.mock('next/navigation', () => ({ + useRouter: () => router, + usePathname: () => pathname, + useSearchParams: () => searchParams, + redirect: vi.fn(), + notFound: vi.fn(), +})); diff --git a/tests/helpers/render.tsx b/tests/helpers/render.tsx new file mode 100644 index 0000000..e159151 --- /dev/null +++ b/tests/helpers/render.tsx @@ -0,0 +1,68 @@ +/** + * Component: Frontend Test Render Helpers + * Documentation: documentation/frontend/components.md + */ + +import React from 'react'; +import { render, RenderOptions } from '@testing-library/react'; +import { SWRConfig, type SWRConfiguration } from 'swr'; +import { resetMockAuthState, setMockAuthState, type MockUser } from './mock-auth'; +import { resetMockRouter, setMockPathname, setMockSearchParams } from './mock-next-navigation'; + +type RenderWithProvidersOptions = Omit & { + auth?: Partial<{ + user: MockUser | null; + accessToken: string | null; + isLoading: boolean; + login: (pinId: number) => Promise; + logout: () => void; + refreshToken: () => Promise; + setAuthData: (user: MockUser, accessToken: string) => void; + }>; + pathname?: string; + searchParams?: string | URLSearchParams; + swr?: SWRConfiguration; + wrapper?: React.ComponentType<{ children: React.ReactNode }>; +}; + +const createWrapper = ( + swr: SWRConfiguration | undefined, + Wrapper: RenderWithProvidersOptions['wrapper'] +) => { + return function WrapperComponent({ children }: { children: React.ReactNode }) { + const content = Wrapper ? {children} : children; + + return ( + new Map(), dedupingInterval: 0, ...swr }}> + {content} + + ); + }; +}; + +export const renderWithProviders = ( + ui: React.ReactElement, + options: RenderWithProvidersOptions = {} +) => { + resetMockAuthState(); + resetMockRouter(); + + if (options.auth) { + setMockAuthState(options.auth); + } + + if (options.pathname) { + setMockPathname(options.pathname); + } + + if (options.searchParams) { + setMockSearchParams(options.searchParams); + } + + const { wrapper, swr, ...renderOptions } = options; + + return render(ui, { + wrapper: createWrapper(swr, wrapper), + ...renderOptions, + }); +}; diff --git a/tests/lib/hooks/useAudiobooks.test.tsx b/tests/lib/hooks/useAudiobooks.test.tsx new file mode 100644 index 0000000..a4eb655 --- /dev/null +++ b/tests/lib/hooks/useAudiobooks.test.tsx @@ -0,0 +1,109 @@ +/** + * Component: Audiobooks Hooks Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const useSWRMock = vi.hoisted(() => vi.fn()); +const authenticatedFetcherMock = vi.hoisted(() => vi.fn()); + +vi.mock('swr', () => ({ + default: useSWRMock, +})); + +vi.mock('@/lib/utils/api', () => ({ + authenticatedFetcher: authenticatedFetcherMock, +})); + +const HookProbe = ({ label, value }: { label: string; value: any }) => ( +
{JSON.stringify(value)}
+); + +describe('useAudiobooks hooks', () => { + beforeEach(() => { + useSWRMock.mockReset(); + authenticatedFetcherMock.mockReset(); + vi.resetModules(); + }); + + it('builds the popular audiobooks endpoint and returns data', async () => { + useSWRMock.mockReturnValue({ + data: { audiobooks: [{ asin: 'a1' }], totalPages: 3, totalCount: 30, hasMore: true }, + error: null, + isLoading: false, + }); + + const { useAudiobooks } = await import('@/lib/hooks/useAudiobooks'); + + const Probe = () => { + const result = useAudiobooks('popular', 10, 2); + return ; + }; + + render(); + + expect(useSWRMock).toHaveBeenCalledWith( + '/api/audiobooks/popular?page=2&limit=10', + authenticatedFetcherMock, + expect.objectContaining({ dedupingInterval: 60000 }) + ); + + const parsed = JSON.parse(screen.getByTestId('popular').textContent || '{}'); + expect(parsed.audiobooks).toHaveLength(1); + expect(parsed.totalPages).toBe(3); + expect(parsed.hasMore).toBe(true); + }); + + it('skips search when the query is empty', async () => { + useSWRMock.mockReturnValue({ data: null, error: null, isLoading: false }); + + const { useSearch } = await import('@/lib/hooks/useAudiobooks'); + + const Probe = () => { + const result = useSearch('', 1); + return ; + }; + + render(); + + expect(useSWRMock).toHaveBeenCalledWith( + null, + authenticatedFetcherMock, + expect.objectContaining({ dedupingInterval: 30000 }) + ); + + const parsed = JSON.parse(screen.getByTestId('search').textContent || '{}'); + expect(parsed.isLoading).toBeFalsy(); + }); + + it('requests audiobook details when an ASIN is provided', async () => { + useSWRMock.mockReturnValue({ + data: { audiobook: { asin: 'a2', title: 'Details' } }, + error: null, + isLoading: false, + }); + + const { useAudiobookDetails } = await import('@/lib/hooks/useAudiobooks'); + + const Probe = () => { + const result = useAudiobookDetails('a2'); + return ; + }; + + render(); + + expect(useSWRMock).toHaveBeenCalledWith( + '/api/audiobooks/a2', + authenticatedFetcherMock, + expect.objectContaining({ dedupingInterval: 300000 }) + ); + + const parsed = JSON.parse(screen.getByTestId('details').textContent || '{}'); + expect(parsed.audiobook.asin).toBe('a2'); + }); +}); diff --git a/tests/lib/hooks/useRequests.test.tsx b/tests/lib/hooks/useRequests.test.tsx new file mode 100644 index 0000000..44c6f85 --- /dev/null +++ b/tests/lib/hooks/useRequests.test.tsx @@ -0,0 +1,220 @@ +/** + * Component: Requests Hooks Tests + * Documentation: documentation/frontend/components.md + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { act, render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const useAuthMock = vi.hoisted(() => vi.fn()); +const useSWRMock = vi.hoisted(() => vi.fn()); +const mutateMock = vi.hoisted(() => vi.fn()); +const fetchWithAuthMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => useAuthMock(), +})); + +vi.mock('swr', () => ({ + default: useSWRMock, + mutate: mutateMock, +})); + +vi.mock('@/lib/utils/api', () => ({ + fetchWithAuth: fetchWithAuthMock, +})); + +const renderHookValue = (hook: () => T) => { + let value: T; + function Probe() { + value = hook(); + return null; + } + render(); + return value!; +}; + +const makeResponse = (body: any, ok = true) => ({ + ok, + json: async () => body, +}); + +describe('useRequests hooks', () => { + beforeEach(() => { + useAuthMock.mockReset(); + useSWRMock.mockReset(); + mutateMock.mockReset(); + fetchWithAuthMock.mockReset(); + vi.resetModules(); + }); + + it('builds request list endpoints when authenticated', async () => { + useAuthMock.mockReturnValue({ accessToken: 'token' }); + useSWRMock.mockReturnValue({ data: { requests: [] }, error: null, isLoading: false }); + + const { useRequests } = await import('@/lib/hooks/useRequests'); + + renderHookValue(() => useRequests('pending', 25, true)); + + expect(useSWRMock).toHaveBeenCalledWith( + '/api/requests?status=pending&limit=25&myOnly=true', + expect.any(Function), + expect.objectContaining({ refreshInterval: 5000 }) + ); + }); + + it('builds request detail endpoints when authenticated', async () => { + useAuthMock.mockReturnValue({ accessToken: 'token' }); + useSWRMock.mockReturnValue({ data: { request: { id: 'req-1' } }, error: null, isLoading: false }); + + const { useRequest } = await import('@/lib/hooks/useRequests'); + + renderHookValue(() => useRequest('req-1')); + + expect(useSWRMock).toHaveBeenCalledWith( + '/api/requests/req-1', + expect.any(Function), + expect.objectContaining({ refreshInterval: 3000 }) + ); + }); + + it('creates requests and triggers revalidation', async () => { + useAuthMock.mockReturnValue({ accessToken: 'token' }); + fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-1' } })); + + const { useCreateRequest } = await import('@/lib/hooks/useRequests'); + const hook = renderHookValue(() => useCreateRequest()); + + await act(async () => { + const result = await hook.createRequest({ asin: 'a1', title: 'Book', author: 'Author' } as any); + expect(result.id).toBe('req-1'); + }); + + expect(fetchWithAuthMock).toHaveBeenCalledWith( + '/api/requests', + expect.objectContaining({ method: 'POST' }) + ); + expect(mutateMock).toHaveBeenCalled(); + }); + + it('surfaces specific create request errors', async () => { + useAuthMock.mockReturnValue({ accessToken: 'token' }); + fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'AlreadyAvailable' }, false)); + + const { useCreateRequest } = await import('@/lib/hooks/useRequests'); + const hook = renderHookValue(() => useCreateRequest()); + + await act(async () => { + await expect( + hook.createRequest({ asin: 'a1', title: 'Book', author: 'Author' } as any) + ).rejects.toThrow('already in your Plex library'); + }); + }); + + it('cancels requests via the API', async () => { + useAuthMock.mockReturnValue({ accessToken: 'token' }); + fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-2' } })); + + const { useCancelRequest } = await import('@/lib/hooks/useRequests'); + const hook = renderHookValue(() => useCancelRequest()); + + await act(async () => { + await hook.cancelRequest('req-2'); + }); + + expect(fetchWithAuthMock).toHaveBeenCalledWith( + '/api/requests/req-2', + expect.objectContaining({ method: 'PATCH' }) + ); + }); + + it('triggers manual search for requests', async () => { + useAuthMock.mockReturnValue({ accessToken: 'token' }); + fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-3' } })); + + const { useManualSearch } = await import('@/lib/hooks/useRequests'); + const hook = renderHookValue(() => useManualSearch()); + + await act(async () => { + await hook.triggerManualSearch('req-3'); + }); + + expect(fetchWithAuthMock).toHaveBeenCalledWith( + '/api/requests/req-3/manual-search', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('searches torrents interactively for a request', async () => { + useAuthMock.mockReturnValue({ accessToken: 'token' }); + fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ results: [{ guid: 't1' }] })); + + const { useInteractiveSearch } = await import('@/lib/hooks/useRequests'); + const hook = renderHookValue(() => useInteractiveSearch()); + + await act(async () => { + const results = await hook.searchTorrents('req-4', 'Custom'); + expect(results).toHaveLength(1); + }); + + expect(fetchWithAuthMock).toHaveBeenCalledWith( + '/api/requests/req-4/interactive-search', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('selects torrents for existing requests', async () => { + useAuthMock.mockReturnValue({ accessToken: 'token' }); + fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-5' } })); + + const { useSelectTorrent } = await import('@/lib/hooks/useRequests'); + const hook = renderHookValue(() => useSelectTorrent()); + + await act(async () => { + await hook.selectTorrent('req-5', { title: 'Torrent' }); + }); + + expect(fetchWithAuthMock).toHaveBeenCalledWith( + '/api/requests/req-5/select-torrent', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('searches torrents for new requests', async () => { + useAuthMock.mockReturnValue({ accessToken: 'token' }); + fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ results: [{ guid: 't2' }] })); + + const { useSearchTorrents } = await import('@/lib/hooks/useRequests'); + const hook = renderHookValue(() => useSearchTorrents()); + + await act(async () => { + const results = await hook.searchTorrents('Title', 'Author', 'asin'); + expect(results).toHaveLength(1); + }); + + expect(fetchWithAuthMock).toHaveBeenCalledWith( + '/api/audiobooks/search-torrents', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('requests torrents with audiobook payloads', async () => { + useAuthMock.mockReturnValue({ accessToken: 'token' }); + fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-6' } })); + + const { useRequestWithTorrent } = await import('@/lib/hooks/useRequests'); + const hook = renderHookValue(() => useRequestWithTorrent()); + + await act(async () => { + await hook.requestWithTorrent({ asin: 'a1', title: 'Book', author: 'Author' } as any, { title: 'Torrent' }); + }); + + expect(fetchWithAuthMock).toHaveBeenCalledWith( + '/api/audiobooks/request-with-torrent', + expect.objectContaining({ method: 'POST' }) + ); + }); +}); diff --git a/tests/lib/utils/api.test.tsx b/tests/lib/utils/api.test.tsx new file mode 100644 index 0000000..05ef5c4 --- /dev/null +++ b/tests/lib/utils/api.test.tsx @@ -0,0 +1,107 @@ +/** + * Component: API Utility Tests + * Documentation: documentation/frontend/routing-auth.md + */ + +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const isTokenExpiredMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/utils/jwt-client', () => ({ + isTokenExpired: isTokenExpiredMock, +})); + +describe('fetchWithAuth', () => { + let locationStub: { href: string; pathname: string }; + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + isTokenExpiredMock.mockReturnValue(false); + locationStub = { href: 'http://localhost/', pathname: '/' }; + vi.stubGlobal('location', locationStub); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('adds Authorization header when access token is available', async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true }); + vi.stubGlobal('fetch', fetchMock); + localStorage.setItem('accessToken', 'access-token'); + + const { fetchWithAuth } = await import('@/lib/utils/api'); + await fetchWithAuth('/api/test'); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/test', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer access-token', + }), + }) + ); + }); + + it('refreshes the access token and retries after a 401 response', async () => { + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (url === '/api/auth/refresh') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ accessToken: 'new-token' }), + }); + } + + return Promise.resolve({ + ok: url !== '/api/test' || fetchMock.mock.calls.length > 1, + status: url === '/api/test' && fetchMock.mock.calls.length === 1 ? 401 : 200, + }); + }); + + vi.stubGlobal('fetch', fetchMock); + + localStorage.setItem('accessToken', 'old-token'); + localStorage.setItem('refreshToken', 'refresh-token'); + + const { fetchWithAuth } = await import('@/lib/utils/api'); + await fetchWithAuth('/api/test'); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + '/api/test', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer new-token', + }), + }) + ); + expect(localStorage.getItem('accessToken')).toBe('new-token'); + }); + + it('logs out when refresh fails', async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 401, ok: false }); + vi.stubGlobal('fetch', fetchMock); + + localStorage.setItem('accessToken', 'old-token'); + localStorage.setItem('refreshToken', 'expired-refresh'); + localStorage.setItem('user', JSON.stringify({ id: 'user-1' })); + + isTokenExpiredMock.mockImplementation((token: string) => token.startsWith('expired')); + + locationStub.pathname = '/requests'; + locationStub.href = 'http://localhost/requests'; + + const { fetchWithAuth } = await import('@/lib/utils/api'); + await fetchWithAuth('/api/test'); + + expect(localStorage.getItem('accessToken')).toBeNull(); + expect(localStorage.getItem('refreshToken')).toBeNull(); + expect(localStorage.getItem('user')).toBeNull(); + expect(window.location.href).toContain('/login?redirect=%2Frequests'); + }); +}); diff --git a/tests/lib/utils/path-template.util.test.ts b/tests/lib/utils/path-template.util.test.ts index 49fd99f..4b13978 100644 --- a/tests/lib/utils/path-template.util.test.ts +++ b/tests/lib/utils/path-template.util.test.ts @@ -316,7 +316,9 @@ describe('getValidVariables', () => { expect(variables).toContain('narrator'); expect(variables).toContain('asin'); expect(variables).toContain('year'); - expect(variables).toHaveLength(5); + expect(variables).toContain('series'); + expect(variables).toContain('seriesPart'); + expect(variables).toHaveLength(7); }); it('should return a new array each time (not mutate original)', () => { diff --git a/tests/processors/organize-files.processor.test.ts b/tests/processors/organize-files.processor.test.ts index c2028f0..d071fe4 100644 --- a/tests/processors/organize-files.processor.test.ts +++ b/tests/processors/organize-files.processor.test.ts @@ -9,6 +9,9 @@ import { createPrismaMock } from '../helpers/prisma'; const prismaMock = createPrismaMock(); const organizerMock = vi.hoisted(() => ({ organize: vi.fn() })); const libraryServiceMock = vi.hoisted(() => ({ triggerLibraryScan: vi.fn() })); +const jobQueueMock = vi.hoisted(() => ({ + addNotificationJob: vi.fn(() => Promise.resolve()), +})); const configMock = vi.hoisted(() => ({ getBackendMode: vi.fn(), get: vi.fn(), @@ -30,6 +33,10 @@ vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configMock, })); +vi.mock('@/lib/services/job-queue.service', () => ({ + getJobQueueService: () => jobQueueMock, +})); + describe('processOrganizeFiles', () => { beforeEach(() => { vi.clearAllMocks(); @@ -74,6 +81,121 @@ describe('processOrganizeFiles', () => { expect(libraryServiceMock.triggerLibraryScan).toHaveBeenCalledWith('lib-1'); }); + it('skips filesystem scan when disabled', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a3', + title: 'Book', + author: 'Author', + narrator: null, + coverArtUrl: null, + audibleAsin: 'ASIN3', + year: 2020, + }); + organizerMock.organize.mockResolvedValue({ + success: true, + targetPath: '/media/Author/Book', + filesMovedCount: 1, + errors: [], + audioFiles: ['/media/Author/Book/Book.m4b'], + }); + prismaMock.audiobook.update.mockResolvedValue({}); + prismaMock.request.update.mockResolvedValue({}); + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.get.mockResolvedValue('false'); + + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + const result = await processOrganizeFiles({ + requestId: 'req-3', + audiobookId: 'a3', + downloadPath: '/downloads/book', + jobId: 'job-3', + }); + + expect(result.success).toBe(true); + expect(libraryServiceMock.triggerLibraryScan).not.toHaveBeenCalled(); + }); + + it('continues when scan is enabled but library ID is missing', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a4', + title: 'Book', + author: 'Author', + narrator: null, + coverArtUrl: null, + audibleAsin: 'ASIN4', + }); + organizerMock.organize.mockResolvedValue({ + success: true, + targetPath: '/media/Author/Book', + filesMovedCount: 1, + errors: [], + audioFiles: ['/media/Author/Book/Book.m4b'], + }); + prismaMock.audiobook.update.mockResolvedValue({}); + prismaMock.request.update.mockResolvedValue({}); + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.get.mockImplementation(async (key: string) => { + if (key === 'plex.trigger_scan_after_import') return 'true'; + if (key === 'plex_audiobook_library_id') return null; + return null; + }); + + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + const result = await processOrganizeFiles({ + requestId: 'req-4', + audiobookId: 'a4', + downloadPath: '/downloads/book', + jobId: 'job-4', + }); + + expect(result.success).toBe(true); + expect(libraryServiceMock.triggerLibraryScan).not.toHaveBeenCalled(); + }); + + it('updates year from AudibleCache when missing', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a5', + title: 'Book', + author: 'Author', + narrator: null, + coverArtUrl: null, + audibleAsin: 'ASIN5', + year: null, + }); + prismaMock.audibleCache.findUnique.mockResolvedValue({ + releaseDate: '2020-01-01', + }); + organizerMock.organize.mockResolvedValue({ + success: true, + targetPath: '/media/Author/Book', + filesMovedCount: 1, + errors: [], + audioFiles: ['/media/Author/Book/Book.m4b'], + }); + prismaMock.audiobook.update.mockResolvedValue({}); + prismaMock.request.update.mockResolvedValue({}); + configMock.getBackendMode.mockResolvedValue('plex'); + configMock.get.mockResolvedValue('false'); + + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + const result = await processOrganizeFiles({ + requestId: 'req-5', + audiobookId: 'a5', + downloadPath: '/downloads/book', + jobId: 'job-5', + }); + + expect(result.success).toBe(true); + expect(prismaMock.audiobook.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ year: 2020 }), + }) + ); + }); + it('queues retry when a retryable error occurs', async () => { prismaMock.request.update.mockResolvedValue({}); prismaMock.audiobook.findUnique.mockResolvedValue({ @@ -116,6 +238,107 @@ describe('processOrganizeFiles', () => { }) ); }); + + it('marks request as warn when max retries exceeded and notifies user', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a6', + title: 'Book', + author: 'Author', + narrator: null, + coverArtUrl: null, + audibleAsin: 'ASIN6', + }); + organizerMock.organize.mockResolvedValue({ + success: false, + targetPath: '', + filesMovedCount: 0, + errors: ['No audiobook files found in download'], + audioFiles: [], + }); + prismaMock.request.findFirst.mockResolvedValue({ + importAttempts: 2, + maxImportRetries: 3, + deletedAt: null, + }); + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-6', + audiobook: { title: 'Book', author: 'Author' }, + user: { plexUsername: 'user' }, + }); + configMock.get.mockResolvedValue(null); + + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + const result = await processOrganizeFiles({ + requestId: 'req-6', + audiobookId: 'a6', + downloadPath: '/downloads/book', + jobId: 'job-6', + }); + + expect(result.success).toBe(false); + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'warn' }), + }) + ); + expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( + 'request_error', + 'req-6', + 'Book', + 'Author', + 'user', + expect.stringContaining('Max retries') + ); + }); + + it('marks request failed for non-retryable errors and notifies user', async () => { + prismaMock.request.update.mockResolvedValue({}); + prismaMock.audiobook.findUnique.mockResolvedValue({ + id: 'a7', + title: 'Book', + author: 'Author', + narrator: null, + coverArtUrl: null, + audibleAsin: 'ASIN7', + }); + organizerMock.organize.mockResolvedValue({ + success: false, + targetPath: '', + filesMovedCount: 0, + errors: ['Unexpected error'], + audioFiles: [], + }); + prismaMock.request.findUnique.mockResolvedValue({ + id: 'req-7', + audiobook: { title: 'Book', author: 'Author' }, + user: { plexUsername: 'user' }, + }); + configMock.get.mockResolvedValue(null); + + const { processOrganizeFiles } = await import('@/lib/processors/organize-files.processor'); + + await expect(processOrganizeFiles({ + requestId: 'req-7', + audiobookId: 'a7', + downloadPath: '/downloads/book', + jobId: 'job-7', + })).rejects.toThrow(/File organization failed/i); + + expect(prismaMock.request.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'failed' }), + }) + ); + expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith( + 'request_error', + 'req-7', + 'Book', + 'Author', + 'user', + expect.stringContaining('File organization failed') + ); + }); }); diff --git a/tests/services/audiobookshelf-api.test.ts b/tests/services/audiobookshelf-api.test.ts index 9da0a79..70f4f98 100644 --- a/tests/services/audiobookshelf-api.test.ts +++ b/tests/services/audiobookshelf-api.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { absRequest, + deleteABSItem, getABSLibraries, getABSLibraryItems, getABSRecentItems, @@ -56,6 +57,21 @@ describe('Audiobookshelf API client', () => { expect(fetchMock).toHaveBeenCalledWith('http://abs/api/status', expect.any(Object)); }); + it('throws when ABS responds with an error status', async () => { + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'audiobookshelf.server_url') return 'http://abs'; + if (key === 'audiobookshelf.api_token') return 'token'; + return null; + }); + fetchMock.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + await expect(absRequest('/status')).rejects.toThrow('ABS API error: 401 Unauthorized'); + }); + it('maps library responses and search queries', async () => { configServiceMock.get.mockImplementation(async (key: string) => { if (key === 'audiobookshelf.server_url') return 'http://abs'; @@ -91,6 +107,20 @@ describe('Audiobookshelf API client', () => { expect(fetchMock.mock.calls[3][0]).toBe('http://abs/api/libraries/lib-1/search?q=hello%20world'); }); + it('returns an empty array when search results are missing', async () => { + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'audiobookshelf.server_url') return 'http://abs'; + if (key === 'audiobookshelf.api_token') return 'token'; + return null; + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + + expect(await searchABSItems('lib-1', 'missing')).toEqual([]); + }); + it('triggers library scan using plain text responses', async () => { configServiceMock.get.mockImplementation(async (key: string) => { if (key === 'audiobookshelf.server_url') return 'http://abs'; @@ -144,4 +174,35 @@ describe('Audiobookshelf API client', () => { await expect(triggerABSItemMatch('item-1', 'ASIN123')).resolves.toBeUndefined(); }); + + it('deletes a library item successfully', async () => { + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'audiobookshelf.server_url') return 'http://abs'; + if (key === 'audiobookshelf.api_token') return 'token'; + return null; + }); + fetchMock.mockResolvedValue({ + ok: true, + }); + + await expect(deleteABSItem('item-1')).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith('http://abs/api/items/item-1', expect.objectContaining({ + method: 'DELETE', + })); + }); + + it('throws when delete fails', async () => { + configServiceMock.get.mockImplementation(async (key: string) => { + if (key === 'audiobookshelf.server_url') return 'http://abs'; + if (key === 'audiobookshelf.api_token') return 'token'; + return null; + }); + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Boom', + }); + + await expect(deleteABSItem('item-1')).rejects.toThrow('ABS API error: 500 Boom'); + }); }); diff --git a/tests/services/notification.service.test.ts b/tests/services/notification.service.test.ts index 6496b9b..30d9aa8 100644 --- a/tests/services/notification.service.test.ts +++ b/tests/services/notification.service.test.ts @@ -30,11 +30,10 @@ vi.mock('@/lib/services/encryption.service', () => ({ getEncryptionService: () => encryptionMock, })); -global.fetch = fetchMock as any; - describe('NotificationService', () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); }); describe('sendNotification', () => { diff --git a/tests/setup.ts b/tests/setup.ts index 71ee325..4b9b5ee 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -3,21 +3,89 @@ * Documentation: documentation/README.md */ -import { beforeAll, afterAll, vi } from 'vitest'; +import React from 'react'; +import { afterAll, afterEach, beforeAll, vi } from 'vitest'; import '@testing-library/jest-dom'; +import { cleanup } from '@testing-library/react'; + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => + React.createElement('a', { href, ...props }, children), +})); + +vi.mock('next/image', () => ({ + default: (props: { src: string | { src: string } }) => { + const resolvedSrc = typeof props.src === 'string' ? props.src : props.src?.src; + return React.createElement('img', { ...props, src: resolvedSrc }); + }, +})); beforeAll(() => { process.env.NODE_ENV = 'test'; process.env.TZ = 'UTC'; + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - if (!globalThis.fetch) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).fetch = () => { - throw new Error('fetch was called without a mock in tests'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).fetch = () => { + throw new Error('fetch was called without a mock in tests'); + }; + + if (!globalThis.requestAnimationFrame) { + globalThis.requestAnimationFrame = (callback: FrameRequestCallback) => { + return setTimeout(() => callback(Date.now()), 0) as unknown as number; }; } + + if (!globalThis.cancelAnimationFrame) { + globalThis.cancelAnimationFrame = (id: number) => { + clearTimeout(id as unknown as NodeJS.Timeout); + }; + } + + if (!globalThis.IntersectionObserver) { + globalThis.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + }; + } + + if (!globalThis.ResizeObserver) { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + } + + if (typeof window !== 'undefined') { + window.scrollTo = window.scrollTo || vi.fn(); + window.open = window.open || vi.fn(); + window.matchMedia = window.matchMedia || ((query: string) => ({ + matches: false, + media: query, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + } + + if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn(); + } }); afterAll(() => { vi.restoreAllMocks(); }); + +afterEach(() => { + if (typeof document !== 'undefined') { + cleanup(); + } +}); diff --git a/vitest.config.ts b/vitest.config.ts index 24114a6..3be69c4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,6 +22,8 @@ export default defineConfig({ clearMocks: true, mockReset: true, restoreMocks: true, + testTimeout: 20000, + hookTimeout: 20000, coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'],