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'],